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

+1 -1
View File
@@ -3,7 +3,7 @@
Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP.
Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec).
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `guild-playlists.test.js` (playlists personnelles / partagées, dossier data temporaire), `stream.edge.js`, `lyrics.test.js` (fetch simulé)
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `guild-playlists.test.js` (playlists personnelles / partagées, dossier data temporaire), `stream.edge.js`, `lyrics.test.js` (fetch simulé), `media.test.js` (conversion Opus avec ffmpeg-static, liens Discord expirants, MediaBase avec un faux Discord)
- Réseau (YouTube / GitHub / LRCLIB réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`, `lyrics.live.js`
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
+67 -1
View File
@@ -79,6 +79,39 @@ const fakePlayer = {
const AllPlayers = new Map([['222222', fakePlayer]]);
const removed = [];
// Stockage des médias simulé (MediaBase) : une entrée Opus valide, une ancienne entrée manquante sur Discord
const HOUR = 3600 * 1000;
const hexSec = (ms) => Math.floor(ms / 1000).toString(16);
const cdnLink = (name, expiresAt) => `https://cdn.discordapp.com/attachments/1/2/${name}?ex=${hexSec(expiresAt)}&is=${hexSec(expiresAt - 24 * HOUR)}&hm=abcdef&`;
let mediaConnected = true;
const mediaCalls = { refresh: [], all: 0, deleted: [] };
const mediaEntries = [
{ id: '900000001', name: 'a.mp3', title: 'Titre A', author: 'Artiste', duration: 200, size: 3000000, originalSize: 8000000, bitrate: 96000,
format: 'opus', createdAt: '2026-10-08T10:00:00.000Z', userId: '333333', channelId: '777', url: cdnLink('a.ogg', Date.now() + 3 * HOUR), thumbnail: null },
{ id: '900000002', name: 'b.mp3', size: 5000000, createdAt: '2026-01-01T10:00:00.000Z', userId: '555555', uploaderName: 'parti',
url: 'https://cdn.discordapp.com/attachments/1/3/b.mp3', missing: true }
];
const fakeMediaBase = {
isConnected: () => mediaConnected,
getStorageStatus: () => ({ configured: true, connected: mediaConnected, guildId: '1', channelId: '2', channelName: 'stockage', premiumTier: 0,
storageLimitBytes: 10 * 1024 * 1024, maxInputBytes: 250 * 1024 * 1024, maxDurationSec: 2411, preferredBitrate: 96000 }),
listEntries: () => mediaEntries.map(e => ({ ...e })),
async refreshMedia(id) {
mediaCalls.refresh.push(id);
const entry = mediaEntries.find(e => e.id === id);
if (!entry) return undefined;
entry.url = cdnLink('a.ogg', Date.now() + 23 * HOUR);
return { ...entry };
},
async refreshAllMedia() { mediaCalls.all++; return { checked: 1, refreshed: 1, missing: 0 }; },
async adminDeleteMedia(id) {
const index = mediaEntries.findIndex(e => e.id === id);
if (index === -1) return null;
mediaCalls.deleted.push(id);
return mediaEntries.splice(index, 1)[0];
}
};
const stubs = {
[norm(SRC + '/server/auth/User')]: {
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
@@ -91,7 +124,7 @@ const stubs = {
getChannel: () => null
},
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/discord/MediaBase')]: {},
[norm(SRC + '/discord/MediaBase')]: fakeMediaBase,
[norm(SRC + '/playlists/PlaylistManager')]: {
removePlaylist(userId, playlistId) {
const list = getDatabase('Playlists').data[userId] || [];
@@ -343,6 +376,39 @@ process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true });
check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r);
console.log('# Médias');
// Un titre du fichier A dans une playlist : compté dans "playlists"
playlistsDb.data['333333'][0].songs.push({ id: '900000001', type: 'attachment' });
r = await call('/ADMIN/MEDIA/LIST', {});
const mediaA = r.ok ? r.data.items.find(i => i.id === '900000001') : null;
const mediaB = r.ok ? r.data.items.find(i => i.id === '900000002') : null;
check('LIST : état du stockage + 2 fichiers', r.ok && r.data.status?.channelName === 'stockage' && r.data.items.length === 2, r);
check('LIST : expiration lue depuis le lien', mediaA && Math.abs(Date.parse(mediaA.expiresAt) - (Date.now() + 3 * HOUR)) < 2000, mediaA?.expiresAt);
check('LIST : propriétaire résolu et référence en playlist', mediaA?.uploader?.username === 'proprio' && mediaA.playlists === 1 && mediaA.format === 'opus', mediaA);
check('LIST : ancien fichier manquant, sans signature, propriétaire supprimé', mediaB?.missing === true && mediaB.expiresAt === null && mediaB.format === 'legacy'
&& mediaB.uploader?.username === 'parti' && mediaB.playlists === 0, mediaB);
check('LIST : plus récent en premier', r.ok && r.data.items[0].id === '900000001');
r = await call('/ADMIN/MEDIA/REFRESH', { id: '900000001' });
check('REFRESH {id} : lien renouvelé', r.ok && mediaCalls.refresh.includes('900000001') && Date.parse(r.data.item.expiresAt) > Date.now() + 20 * HOUR, r);
r = await call('/ADMIN/MEDIA/REFRESH', { id: '123456789' });
check('REFRESH id inconnu -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND', r);
r = await call('/ADMIN/MEDIA/REFRESH', { id: '../x' });
check('REFRESH id invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/MEDIA/REFRESH', {});
check('REFRESH global', r.ok && mediaCalls.all === 1 && r.data.refreshed === 1, r);
mediaConnected = false;
r = await call('/ADMIN/MEDIA/REFRESH', {});
check('REFRESH hors connexion -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
mediaConnected = true;
r = await call('/ADMIN/MEDIA/DELETE', { id: '900000002' });
check('DELETE : supprimé', r.ok && r.data.deleted === true && mediaCalls.deleted.includes('900000002') && mediaEntries.length === 1, r);
r = await call('/ADMIN/MEDIA/DELETE', { id: '900000002' });
check('DELETE déjà supprimé -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND', r);
r = await call('/ADMIN/MEDIA/DELETE', {});
check('DELETE sans id -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/OVERVIEW', {});
check('vue d\'ensemble : compteurs médias présents', r.ok && typeof r.data.media?.files === 'number' && typeof r.data.media?.missing === 'number', r.ok ? r.data.media : r);
console.log('# Journaux');
await sleep(300);
const logDir = path.join(__dirname, 'logs');
+237
View File
@@ -0,0 +1,237 @@
// Stockage des fichiers envoyés : conversion Opus (ffmpeg-static réel), liens Discord expirants,
// MediaBase avec un faux client Discord. Dossier data temporaire : jamais backend/data.
const { BACKEND, SCRATCH, __glob, check, done } = require('./_setup');
const path = require('path');
const fs = require('fs');
const { EventEmitter } = require('events');
const { spawnSync } = require('child_process');
const DATA = fs.mkdtempSync(path.join(SCRATCH, 'media-'));
__glob.MEDIA_DB = path.join(DATA, 'media.json');
__glob.METRIC_FILE = path.join(DATA, 'metrics.json');
const src = (rel) => path.join(BACKEND, 'src', rel);
const ffmpeg = require(path.join(BACKEND, 'node_modules', 'ffmpeg-static'));
const discordUrl = require(src('media/DiscordUrl'));
const audioPrepare = require(src('media/AudioPrepare'));
const HOUR = 3600 * 1000;
const hex = (ms) => Math.floor(ms / 1000).toString(16);
const cdn = (name, expiresAt, n = 0) => `https://cdn.discordapp.com/attachments/111/${222 + n}/${name}?ex=${hex(expiresAt)}&is=${hex(expiresAt - 24 * HOUR)}&hm=${'ab'.repeat(8)}${n}&`;
function makeFixture(file, args) {
const res = spawnSync(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', ...args, file], { windowsHide: true });
if (res.status !== 0) throw new Error('ffmpeg fixture: ' + res.stderr.toString());
return fs.readFileSync(file);
}
(async () => {
console.log('DiscordUrl');
const now = Date.now();
const fresh = cdn('a.ogg', now + 20 * HOUR);
check('lien CDN reconnu', discordUrl.isDiscordCdn(fresh));
check('lien non Discord ignoré', !discordUrl.isDiscordCdn('https://example.com/a.ogg') && !discordUrl.needsRefresh('https://example.com/a.ogg'));
check('http refusé', !discordUrl.isDiscordCdn('http://cdn.discordapp.com/a.ogg'));
check('expiration lue (hex)', Math.abs(discordUrl.getExpiry(fresh) - (now + 20 * HOUR)) < 1000);
check('lien valide : pas de renouvellement', !discordUrl.needsRefresh(fresh, HOUR, now));
check('lien proche de l\'échéance : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now + 30 * 60 * 1000), HOUR, now));
check('lien expiré : renouvellement', discordUrl.needsRefresh(cdn('a.ogg', now - HOUR), HOUR, now));
check('lien non signé : renouvellement', discordUrl.needsRefresh('https://cdn.discordapp.com/attachments/1/2/a.ogg'));
check('base identique malgré la signature', discordUrl.baseOf(fresh) === discordUrl.baseOf(cdn('a.ogg', now + 5 * HOUR)));
console.log('Planification du débit');
const MiB = 1024 * 1024;
check('court : débit préféré (96k)', audioPrepare.planBitrate(180, 10 * MiB) === 96000);
const long = audioPrepare.planBitrate(20 * 60, 10 * MiB);
check('20 min dans 10 Mo : débit réduit ≥ 32k', long >= 32000 && long < 96000 && long % 1000 === 0, long);
check('trop long : refusé', audioPrepare.planBitrate(60 * 60, 10 * MiB) === null);
check('durée inconnue : 64k', audioPrepare.planBitrate(0, 10 * MiB) === 64000);
check('durée max ~38 min pour 10 Mo', Math.abs(audioPrepare.maxDurationFor(10 * MiB) - 2411) < 5, audioPrepare.maxDurationFor(10 * MiB));
check('nom de fichier sûr', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg') === 'Ete_mon_titre.ogg', audioPrepare.safeFileName('../../Été — mon titre!!.mp3', 'ogg'));
check('titre depuis le nom', audioPrepare.titleFromName('Mon_titre_final.wav') === 'Mon titre final');
console.log('Conversion (ffmpeg réel)');
const cover = path.join(DATA, 'cover.png');
makeFixture(cover, ['-f', 'lavfi', '-i', 'color=c=red:s=64x64', '-frames:v', '1']);
const mp3 = makeFixture(path.join(DATA, 'tagged.mp3'), [
'-f', 'lavfi', '-i', 'sine=frequency=440:duration=20', '-i', cover,
'-map', '0:a', '-map', '1:v', '-c:a', 'libmp3lame', '-b:a', '192k', '-c:v', 'mjpeg',
'-disposition:v', 'attached_pic', '-id3v2_version', '3',
'-metadata', 'title=Titre test', '-metadata', 'artist=Artiste test'
]);
const wav = makeFixture(path.join(DATA, 'plain.wav'), ['-f', 'lavfi', '-i', 'sine=frequency=220:duration=8']);
const ok = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 10 * MiB });
check('MP3 converti', ok.ok === true, ok);
check('sortie Ogg/Opus', ok.audio?.subarray(0, 4).toString() === 'OggS' && ok.audio.includes(Buffer.from('OpusHead')));
check('étiquettes lues', ok.meta?.title === 'Titre test' && ok.meta?.artist === 'Artiste test', ok.meta);
check('durée ~20 s', Math.abs((ok.meta?.duration || 0) - 20) < 0.5, ok.meta?.duration);
check('pochette extraite (JPEG)', ok.cover?.subarray(0, 2).toString('hex') === 'ffd8' && ok.coverName === 'cover.jpg');
check('débit 96k', ok.bitrate === 96000, ok.bitrate);
check('nom .ogg', ok.audioName === 'tagged.ogg');
check('plus léger que l\'original', ok.audio.length < mp3.length, [ok.audio.length, mp3.length]);
const plain = await audioPrepare.prepare(wav, 'Ma prise.wav', { limitBytes: 10 * MiB });
check('WAV sans étiquettes : titre du nom', plain.ok && plain.meta.title === 'Ma prise' && plain.meta.artist === null && plain.cover === null, plain.meta);
const tight = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 150 * 1024 });
check('budget serré : débit abaissé et respecté', tight.ok && tight.bitrate < 96000 && tight.audio.length + (tight.cover?.length || 0) <= 150 * 1024, tight.ok ? [tight.bitrate, tight.audio.length] : tight);
const tooLong = await audioPrepare.prepare(mp3, 'tagged.mp3', { limitBytes: 40 * 1024 });
check('trop long pour la limite : TOO_LONG', tooLong.ok === false && tooLong.error === 'TOO_LONG', tooLong);
const garbage = await audioPrepare.prepare(Buffer.from('ceci n\'est pas un fichier audio'.repeat(50)), 'faux.mp3', { limitBytes: 10 * MiB });
check('fichier non audio : INVALID_FILE', garbage.ok === false && garbage.error === 'INVALID_FILE', garbage);
const empty = await audioPrepare.prepare(Buffer.alloc(0), 'vide.mp3', { limitBytes: 10 * MiB });
check('fichier vide : INVALID_FILE', empty.error === 'INVALID_FILE');
check('aucun dossier temporaire laissé', fs.readdirSync(require('os').tmpdir()).filter(e => e.startsWith('chopin-media-')).length === 0);
console.log('MediaBase (faux Discord)');
const { Collection } = require(path.join(BACKEND, 'node_modules', 'discord.js'));
let messageSeq = 900;
const messages = new Map();
const calls = { refresh: 0, fetch: 0, delete: 0 };
let refreshMode = 'ok';
let nextExpiry = now + 20 * HOUR;
function buildMessage(id, files) {
const attachments = new Collection();
files.forEach((f, i) => attachments.set(String(i), { name: f.name, url: cdn(f.name, nextExpiry, Number(id) + i), size: f.size }));
return { id, channelId: 'chan', attachments };
}
const channel = {
id: 'chan',
guild: { premiumTier: 0 },
send: async (opts) => {
const id = String(++messageSeq);
const files = opts.files.map(a => ({ name: a.name, size: a.attachment.length }));
messages.set(id, files);
return buildMessage(id, files);
},
messages: {
fetch: async ({ message }) => {
calls.fetch++;
if (!messages.has(message)) throw Object.assign(new Error('Unknown Message'), { code: 10008 });
return buildMessage(message, messages.get(message));
},
delete: async (id) => { calls.delete++; messages.delete(id); }
}
};
const client = new EventEmitter();
client.isReady = () => true;
client.channels = { fetch: async () => channel };
client.rest = {
post: async (route, { body }) => {
calls.refresh++;
if (route !== '/attachments/refresh-urls') throw new Error('route inattendue');
if (refreshMode === 'fail') throw new Error('refusé');
return { refreshed_urls: body.attachment_urls.map(u => ({ original: u, refreshed: u.replace(/ex=[0-9a-f]+/, 'ex=' + hex(now + 23 * HOUR)) })) };
}
};
const stub = (rel, exports) => {
const file = require.resolve(src(rel));
require.cache[file] = { id: file, filename: file, loaded: true, exports };
};
stub('discord/Bot', { getClient: () => client, getChannel: () => channel });
stub('utils/Database/Configuration', { getMediaGuildId: () => '1', getMediaChannelId: () => '2' });
const mediaBase = require(src('discord/MediaBase'));
check('non connecté avant ready', mediaBase.isConnected() === false);
const refused = await mediaBase.postMedia({ name: 'a.mp3', file: mp3 }, { id: 'u1' });
check('envoi refusé tant que le stockage est indisponible', refused.ok === false && refused.error === 'UNAVAILABLE');
client.emit('ready');
await new Promise(r => setImmediate(r));
check('connecté après ready', mediaBase.isConnected() === true);
const info = mediaBase.getInfo();
check('limite 10 Mo (serveur non boosté)', info.storageLimitBytes === 10 * MiB && info.available === true && info.maxDurationSec > 30 * 60, info);
channel.guild.premiumTier = 3;
check('limite 100 Mo au niveau 3', mediaBase.getStorageLimitBytes() === 100 * MiB);
channel.guild.premiumTier = 0;
const posted = await mediaBase.postMedia({ name: 'tagged.mp3', file: mp3.buffer.slice(mp3.byteOffset, mp3.byteOffset + mp3.length) }, { id: 'u1', username: 'alice' });
check('envoi OK (ArrayBuffer accepté)', posted.ok === true, posted);
check('morceau renvoyé', posted.song?.type === 'attachment' && posted.song.title === 'Titre test' && posted.song.author === 'Artiste test' && Math.round(posted.song.duration) === 20, posted.song);
check('pochette en miniature', /cover\.jpg/.test(posted.song?.thumbnail || ''));
const stored = JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8'));
check('entrée persistée avec identifiant du message', stored.length === 1 && stored[0].id === posted.song.id && stored[0].format === 'opus' && stored[0].userId === 'u1' && stored[0].channelId === 'chan', stored[0]);
const badFile = await mediaBase.postMedia({ name: 'faux.mp3', file: Buffer.from('nope'.repeat(100)) }, { id: 'u1' });
check('fichier invalide refusé sans message Discord', badFile.ok === false && badFile.error === 'INVALID_FILE' && messages.size === 1);
const noName = await mediaBase.postMedia({ name: '', file: mp3 }, { id: 'u1' });
check('nom manquant refusé', noName.ok === false);
let list = await mediaBase.getAllMedia('u1');
check('liste : 1 fichier, aucun renouvellement inutile', list.length === 1 && calls.refresh === 0 && calls.fetch === 0, calls);
check('autre utilisateur : liste vide', (await mediaBase.getAllMedia('u2')).length === 0);
// Lien expiré : renouvellement groupé, l'entrée n'est plus supprimée comme avant
const db = () => JSON.parse(fs.readFileSync(__glob.MEDIA_DB, 'utf8'));
const entry = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[0];
entry.url = cdn('tagged.ogg', now - HOUR, 900);
entry.thumbnail = cdn('cover.jpg', now - HOUR, 901);
list = await mediaBase.getAllMedia('u1');
check('lien expiré renouvelé via refresh-urls', list.length === 1 && calls.refresh === 1 && !discordUrl.needsRefresh(list[0].url) && !discordUrl.needsRefresh(list[0].thumbnail), calls);
check('renouvellement persisté', !discordUrl.needsRefresh(db()[0].url));
// refresh-urls indisponible : relecture du message
refreshMode = 'fail';
entry.url = cdn('tagged.ogg', now - HOUR, 900);
list = await mediaBase.getAllMedia('u1');
check('repli : relecture du message', list.length === 1 && calls.fetch === 1 && !discordUrl.needsRefresh(list[0].url), calls);
// Lecture : lien frais + format Opus (transmis sans ffmpeg)
entry.url = cdn('tagged.ogg', now - HOUR, 900);
const playable = await mediaBase.resolvePlayableUrl({ id: posted.song.id, url: 'https://cdn.discordapp.com/vieux' });
check('lecture : lien frais et format opus', playable && playable.format === 'opus' && !discordUrl.needsRefresh(playable.url), playable);
const forced = await mediaBase.resolvePlayableUrl({ id: posted.song.id }, { force: true });
check('lecture forcée : message relu', forced && calls.fetch === 3, calls);
refreshMode = 'ok';
const foreign = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: cdn('x.mp3', now - HOUR, 5) });
check('pièce jointe hors stockage renouvelée', foreign && foreign.format === null && !discordUrl.needsRefresh(foreign.url), foreign);
const external = await mediaBase.resolvePlayableUrl({ id: 'inconnu', url: 'https://example.com/a.mp3' });
check('lien non Discord inchangé', external?.url === 'https://example.com/a.mp3');
// Message supprimé sur Discord : masqué, mais l'entrée reste en base
const second = await mediaBase.postMedia({ name: 'plain.wav', file: wav }, { id: 'u1', username: 'alice' });
check('second envoi OK', second.ok === true && second.song.title === 'plain');
messages.delete(second.song.id);
const entry2 = require(path.join(BACKEND, 'src', 'utils', 'Database', 'Database')).getDatabase('media').data[1];
entry2.url = cdn('plain.ogg', now - HOUR, 902);
refreshMode = 'fail';
list = await mediaBase.getAllMedia('u1');
check('message supprimé : fichier masqué', list.length === 1 && list[0].id === posted.song.id, list.map(s => s.id));
check('entrée conservée et marquée manquante', db().length === 2 && db()[1].missing === true);
check('lecture d\'un fichier manquant : null', (await mediaBase.resolvePlayableUrl({ id: second.song.id })) === null);
// Suppression
check('suppression par un autre utilisateur refusée', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u2')) === false);
check('suppression OK', (await mediaBase.deleteMedia({ id: posted.song.id }, 'u1')) === true && calls.delete === 1 && !messages.has(posted.song.id));
check('base mise à jour', db().length === 1 && db()[0].id === second.song.id);
console.log('Administration');
let pushes = 0;
process.on('MEDIA_UPDATE', () => { pushes++; });
const status = mediaBase.getStorageStatus();
check('état du stockage', status.connected === true && status.configured === true && status.premiumTier === 0 && status.storageLimitBytes === 10 * MiB, status);
const copy = mediaBase.listEntries();
copy[0].title = 'modifié';
check('listEntries renvoie une copie', mediaBase.listEntries()[0].title !== 'modifié');
check('fichier inconnu : undefined', (await mediaBase.refreshMedia('123')) === undefined);
check('fichier toujours absent de Discord : null', (await mediaBase.refreshMedia(second.song.id)) === null && db()[0].missing === true);
// Message rétabli (salon restauré) : la vérification manuelle lève le marquage
messages.set(second.song.id, [{ name: 'plain.ogg', size: 1000 }]);
const restored = await mediaBase.refreshMedia(second.song.id);
check('fichier rétabli : marquage levé', restored && !restored.missing && !db()[0].missing && !discordUrl.needsRefresh(restored.url), restored);
check('poussée MEDIA_UPDATE à chaque enregistrement', pushes >= 2, pushes);
entry2.url = cdn('plain.ogg', now - HOUR, 902);
refreshMode = 'ok';
const all = await mediaBase.refreshAllMedia();
check('renouvellement global', all.checked === 1 && all.refreshed === 1 && all.missing === 0, all);
const none = await mediaBase.refreshAllMedia();
check('rien à renouveler', none.checked === 0);
const removedByAdmin = await mediaBase.adminDeleteMedia(second.song.id);
check('suppression admin quel que soit le propriétaire', removedByAdmin?.userId === 'u1' && db().length === 0 && !messages.has(second.song.id));
check('suppression admin d\'un inconnu : null', (await mediaBase.adminDeleteMedia(second.song.id)) === null);
done();
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});