Version 2.1.1 - modification d'une certaine UI

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

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
// Stockage des fichiers envoyés : conversion Opus (ffmpeg-static réel), liens Discord expirants,
// MediaBase avec un faux client Discord. Dossier data temporaire : jamais backend/data.
const { BACKEND, SCRATCH, __glob, check, done } = require('./_setup');
const path = require('path');
const fs = require('fs');
const { EventEmitter } = require('events');
const { spawnSync } = require('child_process');
const DATA = fs.mkdtempSync(path.join(SCRATCH, 'media-'));
__glob.MEDIA_DB = path.join(DATA, 'media.json');
__glob.METRIC_FILE = path.join(DATA, 'metrics.json');
const src = (rel) => path.join(BACKEND, 'src', rel);
const ffmpeg = require(path.join(BACKEND, 'node_modules', 'ffmpeg-static'));
const discordUrl = require(src('media/DiscordUrl'));
const audioPrepare = require(src('media/AudioPrepare'));
const HOUR = 3600 * 1000;
const hex = (ms) => Math.floor(ms / 1000).toString(16);
const cdn = (name, expiresAt, n = 0) => `https://cdn.discordapp.com/attachments/111/${222 + n}/${name}?ex=${hex(expiresAt)}&is=${hex(expiresAt - 24 * HOUR)}&hm=${'ab'.repeat(8)}${n}&`;
function makeFixture(file, args) {
const res = spawnSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', ...args, file], { windowsHide: true });
if (res.status !== 0) throw new Error('ffmpeg fixture: ' + res.stderr.toString());
return fs.readFileSync(file);
}
(async () => {
console.log('DiscordUrl');
const now = Date.now();
const fresh = cdn('a.ogg', now + 20 * HOUR);
check('lien CDN reconnu', discordUrl.isDiscordCdn(fresh));
check('lien non Discord ignoré', !discordUrl.isDiscordCdn('https://example.com/a.ogg') && !discordUrl.needsRefresh('https://example.com/a.ogg'));
check('http refusé', !discordUrl.isDiscordCdn('http://cdn.discordapp.com/a.ogg'));
check('expiration lue (hex)', Math.abs(discordUrl.getExpiry(fresh) - (now + 20 * HOUR)) < 1000);
check('lien valide : pas de renouvellement', !discordUrl.needsRefresh(fresh, HOUR, now));
check('lien proche de l\'échéance : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now + 30 * 60 * 1000), HOUR, now));
check('lien expiré : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now - HOUR), HOUR, now));
check('lien non signé : renouvellement', discordUrl.needsRefresh('https://cdn.discordapp.com/attachments/1/2/a.ogg'));
check('base identique malgré la signature', discordUrl.baseOf(fresh) === discordUrl.baseOf(cdn('a.ogg', now + 5 * HOUR)));
console.log('Planification du débit');
const MiB = 1024 * 1024;
check('court : débit préféré (96k)', audioPrepare.planBitrate(180, 10 * MiB) === 96000);
const long = audioPrepare.planBitrate(20 * 60, 10 * MiB);
check('20 min dans 10 Mo : débit réduit ≥ 32k', long >= 32000 && long < 96000 && long % 1000 === 0, long);
check('trop long : refusé', audioPrepare.planBitrate(60 * 60, 10 * MiB) === null);
check('durée inconnue : 64k', audioPrepare.planBitrate(0, 10 * MiB) === 64000);
check('durée max ~38 min pour 10 Mo', Math.abs(audioPrepare.maxDurationFor(10 * MiB) - 2411) < 5, audioPrepare.maxDurationFor(10 * MiB));
check('nom de fichier sûr', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg') === 'Ete_mon_titre.ogg', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg'));
check('titre depuis le nom', audioPrepare.titleFromName('Mon_titre_final.wav') === 'Mon titre final');
console.log('Conversion (ffmpeg réel)');
const cover = path.join(DATA, 'cover.png');
makeFixture(cover, ['-f', 'lavfi', '-i', 'color=c=red:s=64x64', '-frames:v', '1']);
const mp3 = makeFixture(path.join(DATA, 'tagged.mp3'), [
'-f', 'lavfi', '-i', 'sine=frequency=440:duration=20', '-i', cover,
'-map', '0:a', '-map', '1:v', '-c:a', 'libmp3lame', '-b:a', '192k', '-c:v', 'mjpeg',
'-disposition:v', 'attached_pic', '-id3v2_version', '3',
'-metadata', 'title=Titre test', '-metadata', 'artist=Artiste test'
]);
const wav = makeFixture(path.join(DATA, 'plain.wav'), ['-f', 'lavfi', '-i', 'sine=frequency=220:duration=8']);
const ok = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 10 * MiB });
check('MP3 converti', ok.ok === true, ok);
check('sortie Ogg/Opus', ok.audio?.subarray(0, 4).toString() === 'OggS' && ok.audio.includes(Buffer.from('OpusHead')));
check('étiquettes lues', ok.meta?.title === 'Titre test' && ok.meta?.artist === 'Artiste test', ok.meta);
check('durée ~20 s', Math.abs((ok.meta?.duration || 0) - 20) < 0.5, ok.meta?.duration);
check('pochette extraite (JPEG)', ok.cover?.subarray(0, 2).toString('hex') === 'ffd8' && ok.coverName === 'cover.jpg');
check('débit 96k', ok.bitrate === 96000, ok.bitrate);
check('nom .ogg', ok.audioName === 'tagged.ogg');
check('plus léger que l\'original', ok.audio.length < mp3.length, [ok.audio.length, mp3.length]);
const plain = await audioPrepare.prepare(wav, 'Ma prise.wav', { limitBytes: 10 * MiB });
check('WAV sans étiquettes : titre du nom', plain.ok && plain.meta.title === 'Ma prise' && plain.meta.artist === null && plain.cover === null, plain.meta);
const tight = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 150 * 1024 });
check('budget serré : débit abaissé et respecté', tight.ok && tight.bitrate < 96000 && tight.audio.length + (tight.cover?.length || 0) <= 150 * 1024, tight.ok ? [tight.bitrate, tight.audio.length] : tight);
const tooLong = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 40 * 1024 });
check('trop long pour la limite : TOO_LONG', tooLong.ok === false && tooLong.error === 'TOO_LONG', tooLong);
const garbage = await audioPrepare.prepare(Buffer.from('ceci n\'est pas un fichier audio'.repeat(50)), 'faux.mp3', { limitBytes: 10 * MiB });
check('fichier non audio : INVALID_FILE', garbage.ok === false && garbage.error === 'INVALID_FILE', garbage);
const empty = await audioPrepare.prepare(Buffer.alloc(0), 'vide.mp3', { limitBytes: 10 * MiB });
check('fichier vide : INVALID_FILE', empty.error === 'INVALID_FILE');
check('aucun dossier temporaire laissé', fs.readdirSync(require('os').tmpdir()).filter(e => e.startsWith('chopin-media-')).length === 0);
console.log('MediaBase (faux Discord)');
const { Collection } = require(path.join(BACKEND, 'node_modules', 'discord.js'));
let messageSeq = 900;
const messages = new Map();
const calls = { refresh: 0, fetch: 0, delete: 0 };
let refreshMode = 'ok';
let nextExpiry = now + 20 * HOUR;
function buildMessage(id, files) {
const attachments = new Collection();
files.forEach((f, i) => attachments.set(String(i), { name: f.name, url: cdn(f.name, nextExpiry, Number(id) + i), size: f.size }));
return { id, channelId: 'chan', attachments };
}
const channel = {
id: 'chan',
guild: { premiumTier: 0 },
send: async (opts) => {
const id = String(++messageSeq);
const files = opts.files.map(a => ({ name: a.name, size: a.attachment.length }));
messages.set(id, files);
return buildMessage(id, files);
},
messages: {
fetch: async ({ message }) => {
calls.fetch++;
if (!messages.has(message)) throw Object.assign(new Error('Unknown Message'), { code: 10008 });
return buildMessage(message, messages.get(message));
},
delete: async (id) => { calls.delete++; messages.delete(id); }
}
};
const client = new EventEmitter();
client.isReady = () => true;
client.channels = { fetch: async () => channel };
client.rest = {
post: async (route, { body }) => {
calls.refresh++;
if (route !== '/attachments/refresh-urls') throw new Error('route inattendue');
if (refreshMode === 'fail') throw new Error('refusé');
return { refreshed_urls: body.attachment_urls.map(u => ({ original: u, refreshed: u.replace(/ex=[0-9a-f]+/, 'ex=' + hex(now + 23 * HOUR)) })) };
}
};
const stub = (rel, exports) => {
const file = require.resolve(src(rel));
require.cache[file] = { id: file, filename: file, loaded: true, exports };
};
stub('discord/Bot', { getClient: () => client, getChannel: () => channel });
stub('utils/Database/Configuration', { getMediaGuildId: () => '1', getMediaChannelId: () => '2' });
const mediaBase = require(src('discord/MediaBase'));
check('non connecté avant ready', mediaBase.isConnected() === false);
const refused = await mediaBase.postMedia({ name: 'a.mp3', file: mp3 }, { id: 'u1' });
check('envoi refusé tant que le stockage est indisponible', refused.ok === false && refused.error === 'UNAVAILABLE');
client.emit('ready');
await new Promise(r => setImmediate(r));
check('connecté après ready', mediaBase.isConnected() === true);
const info = mediaBase.getInfo();
check('limite 10 Mo (serveur non boosté)', info.storageLimitBytes === 10 * MiB && info.available === true && info.maxDurationSec > 30 * 60, info);
channel.guild.premiumTier = 3;
check('limite 100 Mo au niveau 3', mediaBase.getStorageLimitBytes() === 100 * MiB);
channel.guild.premiumTier = 0;
const posted = await mediaBase.postMedia({ name: 'tagged.mp3', file: mp3.buffer.slice(mp3.byteOffset, mp3.byteOffset + mp3.length) }, { id: 'u1', username: 'alice' });
check('envoi OK (ArrayBuffer accepté)', posted.ok === true, posted);
check('morceau renvoyé', posted.song?.type === 'attachment' && posted.song.title === 'Titre test' && posted.song.author === 'Artiste test' && Math.round(posted.song.duration) === 20, posted.song);
check('pochette en miniature', /cover\.jpg/.test(posted.song?.thumbnail || ''));
const stored = JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8'));
check('entrée persistée avec identifiant du message', stored.length === 1 && stored[0].id === posted.song.id && stored[0].format === 'opus' && stored[0].userId === 'u1' && stored[0].channelId === 'chan', stored[0]);
const badFile = await mediaBase.postMedia({ name: 'faux.mp3', file: Buffer.from('nope'.repeat(100)) }, { id: 'u1' });
check('fichier invalide refusé sans message Discord', badFile.ok === false && badFile.error === 'INVALID_FILE' && messages.size === 1);
const noName = await mediaBase.postMedia({ name: '', file: mp3 }, { id: 'u1' });
check('nom manquant refusé', noName.ok === false);
let list = await mediaBase.getAllMedia('u1');
check('liste : 1 fichier, aucun renouvellement inutile', list.length === 1 && calls.refresh === 0 && calls.fetch === 0, calls);
check('autre utilisateur : liste vide', (await mediaBase.getAllMedia('u2')).length === 0);
// Lien expiré : renouvellement groupé, l'entrée n'est plus supprimée comme avant
const db = () => JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8'));
const entry = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[0];
entry.url = cdn('tagged.ogg', now - HOUR, 900);
entry.thumbnail = cdn('cover.jpg', now - HOUR, 901);
list = await mediaBase.getAllMedia('u1');
check('lien expiré renouvelé via refresh-urls', list.length === 1 && calls.refresh === 1 && !discordUrl.needsRefresh(list[0].url) && !discordUrl.needsRefresh(list[0].thumbnail), calls);
check('renouvellement persisté', !discordUrl.needsRefresh(db()[0].url));
// refresh-urls indisponible : relecture du message
refreshMode = 'fail';
entry.url = cdn('tagged.ogg', now - HOUR, 900);
list = await mediaBase.getAllMedia('u1');
check('repli : relecture du message', list.length === 1 && calls.fetch === 1 && !discordUrl.needsRefresh(list[0].url), calls);
// Lecture : lien frais + format Opus (transmis sans ffmpeg)
entry.url = cdn('tagged.ogg', now - HOUR, 900);
const playable = await mediaBase.resolvePlayableUrl({ id: posted.song.id, url: 'https://cdn.discordapp.com/vieux' });
check('lecture : lien frais et format opus', playable && playable.format === 'opus' && !discordUrl.needsRefresh(playable.url), playable);
const forced = await mediaBase.resolvePlayableUrl({ id: posted.song.id }, { force: true });
check('lecture forcée : message relu', forced && calls.fetch === 3, calls);
refreshMode = 'ok';
const foreign = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: cdn('x.mp3', now - HOUR, 5) });
check('pièce jointe hors stockage renouvelée', foreign && foreign.format === null && !discordUrl.needsRefresh(foreign.url), foreign);
const external = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: 'https://example.com/a.mp3' });
check('lien non Discord inchangé', external?.url === 'https://example.com/a.mp3');
// Message supprimé sur Discord : masqué, mais l'entrée reste en base
const second = await mediaBase.postMedia({ name: 'plain.wav', file: wav }, { id: 'u1', username: 'alice' });
check('second envoi OK', second.ok === true && second.song.title === 'plain');
messages.delete(second.song.id);
const entry2 = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[1];
entry2.url = cdn('plain.ogg', now - HOUR, 902);
refreshMode = 'fail';
list = await mediaBase.getAllMedia('u1');
check('message supprimé : fichier masqué', list.length === 1 && list[0].id === posted.song.id, list.map(s => s.id));
check('entrée conservée et marquée manquante', db().length === 2 && db()[1].missing === true);
check('lecture d\'un fichier manquant : null', (await mediaBase.resolvePlayableUrl({ id: second.song.id })) === null);
// Suppression
check('suppression par un autre utilisateur refusée', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u2')) === false);
check('suppression OK', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u1')) === true && calls.delete === 1 && !messages.has(posted.song.id));
check('base mise à jour', db().length === 1 && db()[0].id === second.song.id);
console.log('Administration');
let pushes = 0;
process.on('MEDIA_UPDATE', () => { pushes++; });
const status = mediaBase.getStorageStatus();
check('état du stockage', status.connected === true && status.configured === true && status.premiumTier === 0 && status.storageLimitBytes === 10 * MiB, status);
const copy = mediaBase.listEntries();
copy[0].title = 'modifié';
check('listEntries renvoie une copie', mediaBase.listEntries()[0].title !== 'modifié');
check('fichier inconnu : undefined', (await mediaBase.refreshMedia('123')) === undefined);
check('fichier toujours absent de Discord : null', (await mediaBase.refreshMedia(second.song.id)) === null && db()[0].missing === true);
// Message rétabli (salon restauré) : la vérification manuelle lève le marquage
messages.set(second.song.id, [{ name: 'plain.ogg', size: 1000 }]);
const restored = await mediaBase.refreshMedia(second.song.id);
check('fichier rétabli : marquage levé', restored && !restored.missing && !db()[0].missing && !discordUrl.needsRefresh(restored.url), restored);
check('poussée MEDIA_UPDATE à chaque enregistrement', pushes >= 2, pushes);
entry2.url = cdn('plain.ogg', now - HOUR, 902);
refreshMode = 'ok';
const all = await mediaBase.refreshAllMedia();
check('renouvellement global', all.checked === 1 && all.refreshed === 1 && all.missing === 0, all);
const none = await mediaBase.refreshAllMedia();
check('rien à renouveler', none.checked === 0);
const removedByAdmin = await mediaBase.adminDeleteMedia(second.song.id);
check('suppression admin quel que soit le propriétaire', removedByAdmin?.userId === 'u1' && db().length === 0 && !messages.has(second.song.id));
check('suppression admin d\'un inconnu : null', (await mediaBase.adminDeleteMedia(second.song.id)) === null);
done();
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});