Version 2.0.0 - update of everything

This commit is contained in:
raphix committed 2026-10-07 14:04:26 +02:00
1 parent 164a1da009
commit 6017e03eac
85 files changed
+13065 -2107

No files matched your search

+1
View File
@@ -0,0 +1 @@
logs/
+10
View File
@@ -0,0 +1,10 @@
# Tests de développement (ad hoc)
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`, `stream.edge.js`
- Réseau (YouTube / GitHub réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
Les données YouTube des tests vont dans `os.tmpdir()/chopin-tests` (jamais `backend/data/`), les logs dans `tests/dev/logs/` (ignoré par git).
+46
View File
@@ -0,0 +1,46 @@
// Environnement de test : loguix dans le scratchpad, données YouTube isolées (jamais backend/data/youtube)
const path = require('path');
const fs = require('fs');
const os = require('os');
const BACKEND = require("path").resolve(__dirname, "../..");
// Données jetables hors du dépôt
const SCRATCH = path.join(os.tmpdir(), 'chopin-tests');
fs.mkdirSync(SCRATCH, { recursive: true });
const LOGS = path.join(__dirname, 'logs');
fs.mkdirSync(LOGS, { recursive: true });
const loguix = require(path.join(BACKEND, 'node_modules', 'loguix'));
loguix.setup(LOGS, path.join(BACKEND, 'package.json'));
const { __glob } = require(path.join(BACKEND, 'src', 'utils', 'GlobalVars'));
function isolateData(name = 'ytdata') {
const dir = fs.mkdtempSync(path.join(SCRATCH, `${name}-`));
__glob.YOUTUBE_DIR = dir;
__glob.YOUTUBE_COOKIES = path.join(dir, 'cookies.txt');
__glob.YOUTUBE_STATE = path.join(dir, 'state.json');
__glob.YOUTUBE_SETTINGS = path.join(dir, 'settings.json');
// Le cache yt-dlp peut rester partagé entre les essais (dans le scratchpad)
__glob.YOUTUBE_CACHE = path.join(SCRATCH, 'ytcache');
// Par défaut aucun ancien fichier : chaque test choisit
__glob.COOKIES = path.join(dir, 'legacy-none.txt');
__glob.COOKIES_JSON = path.join(dir, 'legacy-none.json');
return dir;
}
let failures = 0, passes = 0;
function check(name, cond, extra) {
if (cond) { passes++; console.log(` ok ${name}`); }
else { failures++; console.log(` FAIL ${name}${extra !== undefined ? ' -> ' + JSON.stringify(extra) : ''}`); }
}
function done() {
console.log(`\n${passes} ok, ${failures} échec(s)`);
process.exitCode = failures ? 1 : 0;
}
function req(rel) { return require(path.join(BACKEND, rel)); }
function chopinTmpDirs() {
return fs.readdirSync(os.tmpdir()).filter(e => e.startsWith('chopin-ytc-'));
}
module.exports = { BACKEND, SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs };
+356
View File
@@ -0,0 +1,356 @@
// Routes "/ADMIN/CONFIG/*", "/ADMIN/WARP/CONFIG", "/ADMIN/PLAYLISTS/*" (+ quelques correctifs players / comptes)
// de backend/src/server/Admin.js, appelées par un faux socket. Configuration.js, Database.js et WarpProxy.js sont les
// vrais modules, mais TOUS les chemins de __glob qui pointent vers backend/data sont redirigés vers un dossier
// temporaire (config.json synthétique, jamais backend/data). Aucun appel réseau (Spotify simulé, WARP jamais activé).
const Module = require('module');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { BACKEND, SCRATCH, __glob, isolateData, check, done, req } = require('./_setup');
// --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module ---
const DATA_DIR = path.join(BACKEND, 'data');
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'admin-config-'));
for (const [key, value] of Object.entries(__glob)) {
if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) {
__glob[key] = path.join(tmp, path.relative(DATA_DIR, value));
}
}
__glob.DATA = tmp;
isolateData('admin-config-yt');
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
if (!safePaths) { console.error('Chemins non isolés : arrêt'); process.exit(1); }
process.env.YTDLP_AUTO_UPDATE = 'false';
delete process.env.WARP_PROXY;
delete process.env.WARP_FALLBACK_DIRECT;
// Secrets SYNTHÉTIQUES : aucun ne doit apparaître dans une réponse, une poussée ou un log
const SECRETS = {
token: 'FAKETOKEN.abcdefghijklmnop.qrstuvwxyz0123',
client_secret: 'FAKECLIENTSECRET_9876543210',
youtube: 'FAKEYTSECRET_aaaabbbbcccc',
spotify: 'FAKESPOTIFYSECRET_ddddeeee',
refresh: 'FAKEREFRESHTOKEN_ffffgggg',
proxyPass: 'FAKEPROXYPASS_hhhh'
};
fs.writeFileSync(path.join(tmp, 'config.json'), JSON.stringify({
token: SECRETS.token,
client_secret: SECRETS.client_secret,
report: { channel: '123456789012345678', contact: '' },
api: {
youtube: { clientId: 'yt-client-id.apps.googleusercontent.com', clientSecret: SECRETS.youtube },
spotify: { clientId: 'spotifyclientid', clientSecret: SECRETS.spotify }
},
website: 'https://chopin.example.org',
server_port: 5000,
media: { guildId: '', channelId: '' }
}, null, 2));
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const owner = {
identity: { id: '333333', username: 'proprio', global_name: 'Propriétaire', avatar: null }, labels: [],
isAdmin: () => false, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
// Player simulé : loop / shuffle comptés pour vérifier l'idempotence
const playerCalls = { setLoop: 0, setShuffle: 0 };
const fakePlayer = {
guildId: '222222', loop: true, queue: { shuffle: false, clearNext() {} },
isConnected: () => true,
async setLoop() { playerCalls.setLoop++; this.loop = !this.loop; },
async setShuffle() { playerCalls.setShuffle++; this.queue.shuffle = !this.queue.shuffle; },
getState() {
return {
guildId: '222222', status: 'playing', loop: this.loop, shuffle: this.queue.shuffle,
current: { id: 'song-b', title: 'B' },
lastError: { code: 'SOURCE_FAILED', message: 'x', songId: 'song-a', at: Date.now() },
next: [], previous: []
};
}
};
const AllPlayers = new Map([['222222', fakePlayer]]);
const removed = [];
const stubs = {
[norm(SRC + '/server/auth/User')]: {
getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null,
getUsers: () => [admin, owner]
},
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [...AllPlayers.values()], AllPlayers },
[norm(SRC + '/discord/Bot')]: {
getGuilds: () => guilds, getClient: () => null, isReady: () => false,
getChannel: () => null
},
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/discord/MediaBase')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {
removePlaylist(userId, playlistId) {
const list = getDatabase('Playlists').data[userId] || [];
const index = list.findIndex(p => String(p.playlistId) === String(playlistId));
if (index === -1) return false;
list.splice(index, 1);
removed.push({ userId, playlistId });
return true;
}
},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
};
// Spotify simulé : refus avec le secret recopié dans le message (il doit être masqué)
let spotifyMode = 'reject';
class FakeSpotify {
constructor({ clientId, clientSecret }) { this.clientId = clientId; this.clientSecret = clientSecret; }
async clientCredentialsGrant() {
if (spotifyMode === 'ok') return { body: { access_token: 'FAKEACCESS', expires_in: 3600 } };
throw Object.assign(new Error('Bad Request'), { statusCode: 400, body: { error: 'invalid_client', error_description: 'Invalid client secret ' + this.clientSecret } });
}
}
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request === 'spotify-web-api-node') return FakeSpotify;
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const { Database, getDatabase } = req('src/utils/Database/Database.js');
// Bases réelles dans le dossier temporaire
const playlistsDb = new Database('Playlists', path.join(tmp, 'playlists.json'), {});
playlistsDb.data = {
'333333': [
{ playlistId: '1', title: 'Perso', type: 'playlist', songs: [{ id: 'a' }, { id: 'b' }], duration: 300 },
{ playlistId: '2', title: 'Synchro', type: 'youtube', source: 'google', url: 'https://www.youtube.com/playlist?list=PLx', songs: [{ id: 'c' }], syncedAt: '2026-10-01T10:00:00.000Z' },
{ playlistId: '3', title: 'Spotify', type: 'spotify', url: 'https://open.spotify.com/playlist/x', songs: [] }
],
'444444': []
};
const googleDb = new Database('google', path.join(tmp, 'google.json'), {});
googleDb.data = {
'333333': { tokens: { refresh_token: SECRETS.refresh, access_token: SECRETS.refresh + '_a' }, linkedAt: '2026-10-01' },
'555555': { tokens: { refresh_token: SECRETS.refresh + '_2' }, invalid: true }
};
const configuration = req('src/utils/Database/Configuration.js');
const warp = req('src/utils/WarpProxy.js');
const Admin = req('src/server/Admin.js');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
in: () => ({ socketsLeave() {} }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const leaks = (value) => Object.values(SECRETS).filter(s => JSON.stringify(value).includes(s));
const configEvents = [];
process.on('CONFIG_UPDATE', (payload) => configEvents.push(payload));
const playlistEvents = [];
process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
(async () => {
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE'];
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
console.log('# Configuration : validateur pur');
const bad = (key, value) => { try { configuration.validateValue(key, value); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
check('clé inconnue refusée', bad('api.discord.secret', 'x'));
check('port non numérique refusé', bad('server_port', 'abc'));
check('port hors limites refusé', bad('server_port', 70000));
check('port en texte accepté', configuration.validateValue('server_port', ' 4000 ') === 4000);
check('site javascript: refusé', bad('website', 'javascript:alert(1)'));
check('site avec identifiants refusé', bad('website', 'https://user:pass@example.org'));
check('site avec paramètres refusé', bad('website', 'https://example.org/?a=1'));
check('site normalisé sans / final', configuration.validateValue('website', ' https://Example.org/chopin/ ') === 'https://example.org/chopin');
check('snowflake trop court refusé', bad('report.channel', '123'));
check('snowflake vide accepté (facultatif)', configuration.validateValue('report.channel', '') === '');
check('token vide refusé', bad('token', ' '));
check('secret avec espace refusé', bad('api.spotify.clientSecret', 'abc def'));
check('retour à la ligne refusé', bad('api.spotify.clientId', 'abc\ndef'));
check('type non texte refusé', bad('api.spotify.clientId', 42));
console.log('# CONFIG/GET');
let r = await call('/ADMIN/CONFIG/GET', {});
check('GET ok', r.ok, r);
check('aucun secret dans la réponse', leaks(r).length === 0, leaks(r));
const cfg = r.data.config;
check('toutes les clés de la liste blanche', configuration.getKeys().every(k => cfg[k]), Object.keys(cfg));
check('token masqué {set,length}', cfg.token.secret && cfg.token.set === true && cfg.token.length === SECRETS.token.length && !('value' in cfg.token), cfg.token);
check('secret Spotify masqué', cfg['api.spotify.clientSecret'].set === true && !('value' in cfg['api.spotify.clientSecret']));
check('valeur non secrète renvoyée', cfg.website.value === 'https://chopin.example.org' && cfg['api.spotify.clientId'].value === 'spotifyclientid');
check('redémarrage requis signalé', cfg.server_port.requiresRestart === true && cfg.server_port.pendingRestart === false && r.data.restartRequired === false);
const integ = r.data.integrations;
check('URI de redirection Google exacte', integ.google.redirectUri === 'https://chopin.example.org/oauth2callback', integ.google);
check('Google configuré + comptes liés', integ.google.configured === true && integ.google.linkedUsers === 2 && integ.google.invalidUsers === 1, integ.google);
check('Spotify configuré', integ.spotify.configured === true && integ.spotify.lastTest === null);
check('WARP non configuré', integ.warp.configured === false);
console.log('# CONFIG/SET');
r = await call('/ADMIN/CONFIG/SET', { key: 'token.inconnu', value: 'x' });
check('clé inconnue -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: '__proto__', value: 'x' });
check('__proto__ refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: 'website' });
check('valeur absente -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'ftp://example.org' });
check('site ftp -> INVALID_PARAMS + message FR', !r.ok && r.error === 'INVALID_PARAMS' && /site/i.test(r.message), r);
check('aucun CONFIG_UPDATE sur refus', configEvents.length === 0, configEvents);
emitted.length = 0;
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: ' NEWSPOTIFYSECRET_zz ' });
check('secret Spotify modifié', r.ok && r.data.result.changed === true && r.data.config['api.spotify.clientSecret'].length === 'NEWSPOTIFYSECRET_zz'.length, r);
check('nouveau secret absent de la réponse', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz'));
check('CONFIG_UPDATE émis avec la clé seule', configEvents.length === 1 && configEvents[0].key === 'api.spotify.clientSecret' && Object.keys(configEvents[0]).length === 1, configEvents);
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'config.json'), 'utf-8'));
check('config.json (temporaire) enregistré', onDisk.api.spotify.clientSecret === 'NEWSPOTIFYSECRET_zz' && onDisk.token === SECRETS.token);
check('getter relu à chaud', configuration.getSpotifyClientSecret() === 'NEWSPOTIFYSECRET_zz');
await sleep(450);
const pushes = emitted.filter(e => e.ev === '/ADMIN/CONFIG/UPDATE');
check('poussée /ADMIN/CONFIG/UPDATE vers ADMIN', pushes.length === 1 && pushes[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée sans secret', pushes.length === 1 && leaks(pushes[0].data).length === 0 && !JSON.stringify(pushes[0].data).includes('NEWSPOTIFYSECRET_zz'));
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: 'NEWSPOTIFYSECRET_zz' });
check('même valeur : changed=false, pas d\'événement', r.ok && r.data.result.changed === false && configEvents.length === 1);
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: 4000 });
check('port : redémarrage en attente', r.ok && r.data.result.requiresRestart && r.data.result.pendingRestart && r.data.restartRequired === true, r.data?.result);
r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: '5000' });
check('port remis : plus d\'attente', r.ok && r.data.result.pendingRestart === false && r.data.restartRequired === false);
r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'https://new.example.org/' });
check('site normalisé + URI Google mise à jour', r.ok && r.data.config.website.value === 'https://new.example.org' && r.data.integrations.google.redirectUri === 'https://new.example.org/oauth2callback', r.data?.integrations?.google);
r = await call('/ADMIN/CONFIG/SET', { key: 'report.contact', value: '' });
check('champ facultatif vidé', r.ok && r.data.config['report.contact'].value === '');
r = await call('/ADMIN/CONFIG/SET', { key: 'token', value: '' });
check('token vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
console.log('# CONFIG/SPOTIFY/TEST');
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('test refusé : ok=false, message FR', r.ok && r.data.result.ok === false && /refusés/.test(r.data.result.message), r);
check('secret masqué dans le message', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz') && r.data.result.message.includes('***'));
check('dernier test mémorisé', r.data.integrations.spotify.lastTest?.ok === false);
spotifyMode = 'ok';
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('test réussi', r.ok && r.data.result.ok === true && r.data.result.expiresIn === 3600, r);
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: 'autreid' });
check('dernier test oublié après modification', r.ok && r.data.integrations.spotify.lastTest === null);
r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: '' });
r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {});
check('sans identifiants -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
console.log('# WARP/CONFIG');
const badPatch = (patch, locks) => { try { warp.validateConfigPatch(patch, locks); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } };
const noLocks = { url: false, enabled: false, fallbackDirect: false };
check('ftp refusé', badPatch({ url: 'ftp://127.0.0.1:21' }, noLocks));
check('socks5 sans port refusé', badPatch({ url: 'socks5://127.0.0.1' }, noLocks));
check('chemin refusé', badPatch({ url: 'http://127.0.0.1:1080/x' }, noLocks));
check('booléen attendu', badPatch({ fallbackDirect: 'oui' }, noLocks));
check('champ inconnu refusé', badPatch({ host: 'x' }, noLocks));
check('socks5h accepté', warp.validateConfigPatch({ url: 'socks5h://warp:1080' }, noLocks).url === 'socks5h://warp:1080');
check('URL vide = suppression', warp.validateConfigPatch({ url: ' ' }, noLocks).url === null);
check('verrou env respecté (pur)', badPatch({ url: 'http://127.0.0.1:1080' }, { ...noLocks, url: true }));
r = await call('/ADMIN/WARP/CONFIG', {});
check('aucune modification -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/WARP/CONFIG', { url: 'gopher://x' });
check('URL invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /http/.test(r.message), r);
r = await call('/ADMIN/WARP/CONFIG', { enabled: true });
check('activer sans URL refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
// Désactivé : aucun contrôle réseau
r = await call('/ADMIN/WARP/CONFIG', { enabled: false, url: `http://user:${SECRETS.proxyPass}@127.0.0.1:9`, fallbackDirect: false });
check('configuration enregistrée', r.ok && r.data.changes.length >= 2, r);
check('identifiants jamais renvoyés', r.ok && !JSON.stringify(r).includes(SECRETS.proxyPass) && r.data.status.file.url === 'http://***@127.0.0.1:9' && r.data.status.file.hasCredentials === true, r.data?.status?.file);
check('proxy désactivé : non configuré', r.data.status.configured === false && r.data.status.fallbackDirect === false);
const proxyFile = JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8'));
check('proxy.json (temporaire) écrit', proxyFile.enabled === false && proxyFile.url.includes('127.0.0.1:9') && path.dirname(__glob.PROXY) === tmp);
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: true });
check('modifier une option garde l\'URL', r.ok && JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')).url.includes(SECRETS.proxyPass) && r.data.status.fallbackDirect === true);
process.env.WARP_PROXY = 'http://127.0.0.1:9';
process.env.WARP_FALLBACK_DIRECT = 'true';
r = await call('/ADMIN/WARP/STATUS', {});
check('verrous exposés', r.ok && r.data.locked.url && r.data.locked.enabled && r.data.locked.fallbackDirect && r.data.source === 'env', r.data?.locked);
r = await call('/ADMIN/WARP/CONFIG', { url: 'http://127.0.0.1:1080' });
check('URL verrouillée par WARP_PROXY', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_PROXY'), r);
r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: false });
check('repli verrouillé par WARP_FALLBACK_DIRECT', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_FALLBACK_DIRECT'), r);
delete process.env.WARP_PROXY;
delete process.env.WARP_FALLBACK_DIRECT;
r = await call('/ADMIN/WARP/CONFIG', { url: '' });
check('URL effacée', r.ok && r.data.status.file.url === null && r.data.status.configured === false, r.data?.status);
console.log('# PLAYLISTS');
r = await call('/ADMIN/PLAYLISTS/LIST', {});
check('LIST ok', r.ok && Array.isArray(r.data), r);
check('propriétaires sans playlist ignorés', r.data.length === 1 && r.data[0].userId === '333333');
const entry = r.data[0];
check('forme propriétaire', entry.username === 'proprio' && entry.owner?.id === '333333' && entry.owner.global_name === 'Propriétaire' && entry.owner.isDeleted === false, entry.owner);
const lists = entry.playlists;
check('forme playlist', lists.length === 3 && lists.every(p => typeof p.playlistId === 'string' && 'title' in p && 'type' in p && 'source' in p && typeof p.songCount === 'number'), lists);
check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0);
check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z');
check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p)));
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' });
check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0);
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' });
check('userId invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '99' });
check('playlist inconnue -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND');
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '2' });
check('suppression ok', r.ok && r.data.deleted === true && removed.length === 1 && removed[0].playlistId === '2', r);
check('PLAYLISTS_UPDATE émis pour le propriétaire', playlistEvents.includes('333333'), playlistEvents);
console.log('# Players / comptes (correctifs)');
r = await call('/ADMIN/PLAYERS/LIST', {});
check('erreur d\'une autre musique marquée résolue', r.ok && r.data[0]?.lastErrorStale === true, r.data?.[0]);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: true });
check('loop=true déjà actif : aucune bascule', r.ok && playerCalls.setLoop === 0 && fakePlayer.loop === true, r);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: false });
check('loop=false : une bascule', r.ok && playerCalls.setLoop === 1 && fakePlayer.loop === false);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle', value: false });
check('shuffle=false déjà inactif : aucune bascule', r.ok && playerCalls.setShuffle === 0);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle' });
check('shuffle sans valeur : bascule (ancien client)', r.ok && playerCalls.setShuffle === 1);
r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: 'oui' });
check('valeur non booléenne refusée', !r.ok && r.error === 'INVALID_PARAMS');
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('# Journaux');
await sleep(300);
const logDir = path.join(__dirname, 'logs');
const logText = fs.readdirSync(logDir).map(f => { try { return fs.readFileSync(path.join(logDir, f), 'utf-8'); } catch (e) { return ''; } }).join('\n');
const logged = [...Object.values(SECRETS), 'NEWSPOTIFYSECRET_zz'].filter(s => logText.includes(s));
check('aucun secret dans les logs', logged.length === 0, logged);
check('backend/data jamais utilisé', !fs.existsSync(path.join(DATA_DIR, 'config.json.tmp')));
fs.rmSync(tmp, { recursive: true, force: true });
done();
setTimeout(() => process.exit(process.exitCode || 0), 50);
})().catch((e) => { console.error(e); process.exit(1); });
+247
View File
@@ -0,0 +1,247 @@
// Routes "/ADMIN/YOUTUBE/*" de backend/src/server/Admin.js, appelées par un faux socket.
// Dépendances lourdes (bot Discord, utilisateurs, players, bases...) simulées ; données YouTube isolées
// dans le scratchpad (jamais backend/data). Aucun appel réseau : yt-dlp (run/update/check/test) est simulé.
const Module = require('module');
const path = require('path');
const fs = require('fs');
const { BACKEND, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('admin-yt');
process.env.YTDLP_AUTO_UPDATE = 'false';
process.env.YTDLP_COOKIES_ON_PROXY = 'false'; // verrouillé par l'environnement
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs ---
const admin = {
identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'],
isAdmin: () => true, isFullBanned: () => false
};
const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]);
const stubs = {
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === '111111' ? admin : null, getUsers: () => [admin] },
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [], AllPlayers: new Map() },
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
[norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {},
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => null, getMediaChannelId: () => null },
[norm(SRC + '/utils/Database/Database')]: { getDatabase: () => null },
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map([['222222', { pid: 1 }]]) },
};
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const Admin = req('src/server/Admin.js');
const youtube = req('src/youtube');
// --- Faux socket / io ---
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const emitted = [];
const rooms = new Map([['ADMIN', new Set(['sock1'])]]);
const fakeIo = {
sockets: { adapter: { rooms }, sockets: new Map() },
to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }),
emit() {},
of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
Admin.init(fakeIo);
function call(name, payload) {
return new Promise((resolve) => handlers.get(name)(payload, resolve));
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Cookies SYNTHÉTIQUES (valeurs factices), domaine YouTube + un domaine étranger
const SECRET = 'FAKESECRET';
const exp = Math.floor(Date.now() / 1000) + 30 * 86400;
const line = (d, n, v) => [d, 'TRUE', '/', 'TRUE', String(exp), n, v].join('\t');
const COOKIES_OK = ['# Netscape HTTP Cookie File',
line('.youtube.com', 'LOGIN_INFO', SECRET + '_login'),
line('.youtube.com', 'SAPISID', SECRET + '_sapisid'),
line('.youtube.com', '__Secure-3PAPISID', SECRET + '_3p'),
line('.youtube.com', 'SID', SECRET + '_sid'),
line('.example.org', 'tracker', SECRET + '_other')].join('\r\n');
const COOKIES_ANON = ['# Netscape HTTP Cookie File', line('.youtube.com', 'YSC', SECRET + '_ysc')].join('\n');
(async () => {
const expectedRoutes = ['STATUS', 'COOKIES/IMPORT', 'COOKIES/BROWSER', 'COOKIES/CHECK', 'COOKIES/DELETE', 'SETTINGS', 'TEST', 'UPDATE', 'DIAGNOSTICS/CLEAR'];
check('routes enregistrées', expectedRoutes.every(r => handlers.has('/ADMIN/YOUTUBE/' + r)));
// Vérification en direct simulée (pas de réseau)
let checkCalls = 0;
youtube.check.checkCookies = async () => {
checkCalls++;
const result = { at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'Compte reconnu', cls: null, route: 'direct' };
youtube.cookies.setLastCheck(result);
return { ...result, rotated: false, ms: 5, status: youtube.cookies.getStatus() };
};
console.log('# STATUS');
let r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('STATUS ok', r.ok, r);
check('STATUS forme', r.data && r.data.ytdlp && r.data.cookies && r.data.settings && r.data.diagnostics && Array.isArray(r.data.recent));
check('aucun cookie au départ', r.data.cookies.present === false);
check('cookiesOnProxy verrouillé', r.data.settings.cookiesOnProxy.locked === true);
console.log('# IMPORT');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: '' });
check('contenu vide refusé', !r.ok && r.error === 'INVALID_PARAMS', r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'x'.repeat(1000001) });
check('contenu > 1 000 000 refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'browser:firefox' });
check('source interdite refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: 'du texte qui ne ressemble à rien' });
check('contenu illisible -> INVALID_PARAMS avec message FR', !r.ok && r.error === 'INVALID_PARAMS' && /cookie/i.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_ANON, source: 'paste' });
check('import sans compte : ok + avertissement', r.ok && r.data.warnings.some(w => /connexion/i.test(w)) && r.data.checking === false, r.data?.warnings);
r = await call('/ADMIN/YOUTUBE/COOKIES/IMPORT', { content: COOKIES_OK, source: 'file' });
check('import connecté ok', r.ok && r.data.status.cookies.loggedIn && r.data.status.cookies.valid, r);
check('avertissement domaine étranger', r.ok && r.data.warnings.some(w => /autres sites/.test(w)), r.data?.warnings);
check('vérification lancée', r.data.checking === true);
check('source / auteur enregistrés', r.data.status.cookies.source === 'file' && r.data.status.cookies.importedBy?.username === 'testadmin');
check('aucune valeur de cookie dans la réponse', !JSON.stringify(r).includes(SECRET));
check('fichier maître dans le dossier isolé', fs.existsSync(path.join(dataDir, 'cookies.txt')));
await sleep(50);
check('checkCookies appelé', checkCalls === 1);
console.log('# Poussée');
emitted.length = 0;
await sleep(700);
const push = emitted.filter(e => e.ev === '/ADMIN/YOUTUBE/UPDATE');
check('poussée /ADMIN/YOUTUBE/UPDATE (coalescée)', push.length === 1 && push[0].room === 'ADMIN', emitted.map(e => e.ev));
check('poussée contient lastCheck', push[0]?.data?.cookies?.lastCheck?.ok === true);
rooms.set('ADMIN', new Set());
emitted.length = 0;
process.emit('YOUTUBE_UPDATE');
await sleep(700);
check('pas de poussée si room ADMIN vide', emitted.length === 0);
rooms.set('ADMIN', new Set(['sock1']));
console.log('# CHECK');
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK ok', r.ok && r.data.result.ok === true && r.data.result.status === undefined && r.data.status.cookies.lastCheck, r);
console.log('# SETTINGS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'always', playerClients: 'web_safari, tv' } });
check('SETTINGS appliqués', r.ok && r.data.settings.cookiesMode.value === 'always' && r.data.settings.playerClients.value === 'web_safari,tv', r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesOnProxy: true } });
check('clé verrouillée refusée', !r.ok && r.error === 'INVALID_PARAMS' && /YTDLP_COOKIES_ON_PROXY/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { seekStrategy: 'n importe quoi' } });
check('valeur invalide refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { inconnu: 1 } });
check('clé inconnue refusée', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: {} });
check('patch vide refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: [1] });
check('patch tableau refusé', !r.ok && r.error === 'INVALID_PARAMS');
// Retour aux valeurs par défaut
r = await call('/ADMIN/YOUTUBE/SETTINGS', { patch: { cookiesMode: 'fallback', playerClients: '' } });
check('SETTINGS remis par défaut', r.ok && r.data.settings.cookiesMode.value === 'fallback' && r.data.settings.playerClients.value === '');
check('settings.json dans le dossier isolé', fs.existsSync(path.join(dataDir, 'settings.json')));
console.log('# TEST');
let testArgs = null;
const realTest = youtube.check.testPlayback;
youtube.check.testPlayback = async (opts) => { testArgs = opts; return { ok: true, route: 'direct', identity: 'anon', clients: '', cls: null, message: 'Extraction réussie', format: { id: '251' }, ms: 1, version: 'x' }; };
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'direct', identity: 'anon', url: '', clients: '' });
check('TEST transmis (url / clients vides -> réglages)', r.ok && testArgs.route === 'direct' && testArgs.url === undefined && testArgs.clients === undefined, testArgs);
r = await call('/ADMIN/YOUTUBE/TEST', { route: 'moon' });
check('route invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { identity: 'root' });
check('identité invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/TEST', { url: 'javascript:alert(1)' });
check('url non http refusée', !r.ok && r.error === 'INVALID_PARAMS');
youtube.check.testPlayback = async () => { const e = new Error('Un test de lecture est déjà en cours'); e.code = 'BUSY'; throw e; };
r = await call('/ADMIN/YOUTUBE/TEST', {});
check('BUSY -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r);
youtube.check.testPlayback = realTest;
// Vraie fonction, sans réseau : clients invalides -> INVALID_PARAMS
r = await call('/ADMIN/YOUTUBE/TEST', { clients: 'web;rm' });
check('clients invalides (vrai testPlayback) -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r);
console.log('# UPDATE');
youtube.ytdlp.update = async ({ reason }) => ({ ok: true, updatable: true, before: '1', after: '2', message: 'Mise à jour : 1 → 2', output: '', at: new Date().toISOString(), reason });
r = await call('/ADMIN/YOUTUBE/UPDATE', {});
check('UPDATE ok', r.ok && r.data.result.after === '2' && /testadmin/.test(r.data.result.reason) && r.data.status.ytdlp, r);
console.log('# BROWSER (yt-dlp simulé)');
const realRun = youtube.ytdlp.run;
let runArgs = null;
youtube.ytdlp.run = async (args) => {
runArgs = args;
const file = args[args.indexOf('--cookies') + 1];
fs.writeFileSync(file, COOKIES_OK + '\n' + line('.bank.example', 'session', SECRET + '_bank'));
return { code: 1, stdout: '', stderr: 'ERROR: [youtube] x: Sign in to confirm you’re not a bot', timedOut: false };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'default-release' });
check('BROWSER importe malgré l\'échec d\'extraction', r.ok && r.data.status.cookies.source === 'browser:firefox' && r.data.status.cookies.loggedIn, r);
check('arguments --cookies-from-browser', runArgs && runArgs[runArgs.indexOf('--cookies-from-browser') + 1] === 'firefox:default-release' && runArgs.includes('--skip-download'));
const tmpDir = path.dirname(runArgs[runArgs.indexOf('--cookies') + 1]);
check('fichier temporaire du navigateur supprimé', !fs.existsSync(tmpDir), tmpDir);
check('cookies étrangers non stockés', !fs.readFileSync(path.join(dataDir, 'cookies.txt'), 'utf-8').includes('bank'));
youtube.ytdlp.run = async () => ({ code: 1, stdout: '', stderr: 'ERROR: could not find firefox cookies database in /x --cookies /secret/path', timedOut: false });
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox' });
check('échec navigateur -> UNAVAILABLE explicite', !r.ok && r.error === 'UNAVAILABLE' && /même machine/.test(r.message) && !r.message.includes('/secret/path'), r);
youtube.ytdlp.run = async (args) => {
fs.writeFileSync(args[args.indexOf('--cookies') + 1], '# Netscape HTTP Cookie File\n' + line('.bank.example', 'session', 'x'));
return { code: 0, stdout: '', stderr: '' };
};
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'chrome' });
check('navigateur sans cookie YouTube -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /chrome/.test(r.message), r);
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'netscape' });
check('navigateur inconnu refusé', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/YOUTUBE/COOKIES/BROWSER', { browser: 'firefox', profile: 'a::b' });
check('profil invalide refusé', !r.ok && r.error === 'INVALID_PARAMS');
youtube.ytdlp.run = realRun;
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# OVERVIEW');
r = await call('/ADMIN/OVERVIEW', {});
const yt = r.data?.ytdlp;
check('overview.ytdlp compatible', r.ok && yt && 'version' in yt && yt.cookies.present === true && yt.cookies.updatedAt === yt.cookies.importedAt && yt.activeProcesses === 1, r.ok ? yt : r);
check('overview.ytdlp enrichi', yt && yt.cookies.valid === true && 'recentFailureRate' in yt && 'lastFailure' in yt);
console.log('# DIAGNOSTICS');
youtube.diagnostics.record({ guildId: '222222', songId: 's', title: 'Titre', route: 'direct', identity: 'anon', outcome: 'failed', cls: 'HTTP_403', message: 'HTTP Error 403', ms: 100 });
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('recent avec nom du serveur', r.data.recent[0]?.guildName === 'Serveur Test' && r.data.diagnostics.byClass.HTTP_403 === 1, r.data.recent[0]);
r = await call('/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR', {});
check('CLEAR', r.ok && r.data.recent.length === 0 && r.data.diagnostics.total === 0);
console.log('# DELETE');
r = await call('/ADMIN/YOUTUBE/COOKIES/DELETE', {});
check('DELETE', r.ok && r.data.cookies.present === false && !fs.existsSync(path.join(dataDir, 'cookies.txt')));
r = await call('/ADMIN/YOUTUBE/COOKIES/CHECK', {});
check('CHECK sans cookies -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE');
console.log('# Droits');
admin.labels = []; admin.isAdmin = () => false;
r = await call('/ADMIN/YOUTUBE/STATUS', {});
check('non-admin refusé', !r.ok && r.error === 'FORBIDDEN');
admin.isAdmin = () => true; admin.labels = ['ADMIN'];
// Journaux : aucune valeur de cookie
await sleep(300);
const logs = path.join(__dirname, 'logs');
let leaked = false;
for (const f of fs.readdirSync(logs)) if (fs.readFileSync(path.join(logs, f), 'utf-8').includes(SECRET)) leaked = f;
check('aucune valeur de cookie dans les logs', !leaked, leaked);
const auditLines = fs.readdirSync(logs).flatMap(f => fs.readFileSync(path.join(logs, f), 'utf-8').split('\n')).filter(l => l.includes('[ADMIN] testadmin -> ') && /YouTube|yt-dlp/.test(l));
check('audits écrits', auditLines.length >= 6, auditLines.length);
console.log(auditLines.slice(-12).map(l => ' ' + l.trim()).join('\n'));
done();
setTimeout(() => process.exit(process.exitCode), 100);
})().catch((e) => { console.error(e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
// Test de getYouTubeProfilePicture (non exportée) : module recompilé avec une exportation de test
const path = require("path"), fs = require("fs"), Module = require("module"), assert = require("assert");
const SRC = require("path").resolve(__dirname, "../../src");
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
const file = path.join(SRC, "media/YoutubeInformation.js");
const m = new Module(file, module); m.filename = file; m.paths = Module._nodeModulePaths(path.dirname(file));
m._compile(fs.readFileSync(file, "utf8") + "\nmodule.exports.__avatar = getYouTubeProfilePicture;", file);
const get = m.exports.__avatar;
(async () => {
let t = Date.now();
const a1 = await get("https://www.youtube.com/@aftkt");
console.log("avatar #1:", Date.now() - t, "ms", a1);
assert.ok(a1 && a1.startsWith("https://"));
t = Date.now();
const a2 = await get("https://www.youtube.com/@aftkt");
console.log("avatar #2 (cache):", Date.now() - t, "ms");
assert.strictEqual(a2, a1); assert.ok(Date.now() - t < 20);
assert.strictEqual(await get("https://evil.example.com/@x"), null);
assert.strictEqual(await get("https://www.youtube.com/channel/undefined"), null);
assert.strictEqual(await get(undefined), null);
console.log("OK");
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
+75
View File
@@ -0,0 +1,75 @@
// check.js (test de lecture, vérification des cookies), settings (env verrouillé), index (init, getStatus)
const fs = require('fs');
const path = require('path');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('check');
process.env.YTDLP_POT_PROVIDER = 'http://user:pw@bgutil-provider:4416';
process.env.YTDLP_SEEK_STRATEGY = 'nimportequoi';
// Pas de mise à jour automatique pendant ce test (limite de l'API GitHub)
process.env.YTDLP_AUTO_UPDATE = 'false';
const youtube = req('src/youtube');
const { settings, cookies, check: ytcheck, ytdlp } = youtube;
(async () => {
console.log('# settings');
const pub = settings.getPublic();
check('potProviderUrl verrouillé + masqué', pub.potProviderUrl.locked && pub.potProviderUrl.value === 'http://***@bgutil-provider:4416', pub.potProviderUrl);
check('env invalide ignoré (seekStrategy)', pub.seekStrategy.locked === false && pub.seekStrategy.value === 'auto');
let err = null;
try { settings.update({ potProviderUrl: 'http://x' }); } catch (e) { err = e; }
check('clé verrouillée refusée', err?.code === 'INVALID_PARAMS', err?.message);
err = null;
try { settings.update({ playerClients: 'web;rm -rf' }); } catch (e) { err = e; }
check('clients invalides refusés', err?.code === 'INVALID_PARAMS');
err = null;
try { settings.update({ testUrl: 'https://evil.com/watch?v=x' }); } catch (e) { err = e; }
check('testUrl non YouTube refusée', err?.code === 'INVALID_PARAMS');
err = null;
try { settings.update({ inconnu: 1 }); } catch (e) { err = e; }
check('clé inconnue refusée', err?.code === 'INVALID_PARAMS');
settings.update({ cookiesOnProxy: 'true', playerClients: ' Web , mweb ' }, { username: 'test' });
check('normalisation + écriture', settings.get().cookiesOnProxy === true && settings.get().playerClients === 'web,mweb' && JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8')).playerClients === 'web,mweb');
check('env non écrit dans settings.json', !('potProviderUrl' in JSON.parse(fs.readFileSync(__glob.YOUTUBE_SETTINGS, 'utf8'))));
settings.update({ playerClients: '', cookiesOnProxy: false });
const base = ytdlp.baseArgs({ proxy: 'http://warp:1080', cookiesPath: '/tmp/x', playerClients: 'mweb' });
console.log(' baseArgs :', youtube.errors.sanitize(base.join(' ')));
check('baseArgs : pot + client + proxy + cookies + -4', base.includes('-4') && base.includes('youtubepot-bgutilhttp:base_url=http://user:pw@bgutil-provider:4416') && base.includes('youtube:player_client=mweb') && base.includes('--proxy') && base.includes('--cookies') && !base.includes('--no-cache-dir'));
// Pas de bgutil local pour les essais réels
delete process.env.YTDLP_POT_PROVIDER;
settings.reload();
console.log('# init + getStatus');
await youtube.init();
const st = youtube.getStatus();
console.log(' getStatus :', JSON.stringify({ ytdlp: { ...st.ytdlp, path: path.basename(st.ytdlp.path) }, ffmpeg: st.ffmpeg.source, cookies: { present: st.cookies.present, valid: st.cookies.valid }, pot: st.pot, activeProcesses: st.activeProcesses, diagnostics: st.diagnostics.total }));
check('getStatus : clés attendues', ['ytdlp', 'ffmpeg', 'cookies', 'settings', 'pot', 'diagnostics', 'activeProcesses'].every(k => k in st));
check('dossier cache créé', fs.existsSync(__glob.YOUTUBE_CACHE));
console.log('# testPlayback (anonyme, directe)');
const t = await ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
console.log(' ', JSON.stringify(t));
check('testPlayback ok + format', t.ok && t.format && t.format.id, t);
let busy = null;
const p1 = ytcheck.testPlayback({ route: 'direct', identity: 'anon' });
try { await ytcheck.testPlayback({}); } catch (e) { busy = e.code; }
await p1;
check('testPlayback : un seul à la fois (BUSY)', busy === 'BUSY');
const bad = await ytcheck.testPlayback({ url: '--exec=calc' });
check('testPlayback : URL refusée', bad.ok === false && /URL/.test(bad.message));
console.log('# checkCookies (sans cookies puis anciens cookies)');
const none = await ytcheck.checkCookies();
check('checkCookies sans cookies', none.ok === false && /Aucun cookie/.test(none.message));
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
cookies.migrateLegacy();
const [c1, c2] = await Promise.all([ytcheck.checkCookies(), ytcheck.checkCookies()]);
console.log(' ', JSON.stringify({ ok: c1.ok, loggedIn: c1.loggedIn, rotated: c1.rotated, cls: c1.cls, message: c1.message, route: c1.route, ms: c1.ms }));
check('checkCookies : appel unique partagé', c1 === c2);
check('checkCookies : lastCheck enregistré', cookies.getStatus().lastCheck?.at && cookies.getStatus().lastCheck.message === c1.message);
check('aucune copie chopin-ytc-* restante', chopinTmpDirs().length === 0, chopinTmpDirs());
check('aucun processus actif', ytdlp.activeCount() === 0);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})().catch((e) => { console.error(e); process.exitCode = 1; });
+183
View File
@@ -0,0 +1,183 @@
const fs = require('fs');
const path = require('path');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('cookies');
const cookies = req('src/youtube/cookies');
const NOW = 1791360000; // 2026-10-07
const FUT = 1830000000; // 2027-12
const PAST = 1700000000;
const T = (...f) => f.join('\t');
let events = 0;
process.on('YOUTUBE_UPDATE', () => events++);
function throwsInvalid(fn) {
try { fn(); return false; } catch (e) { return e.code === 'INVALID_COOKIES' && /[a-zé]/.test(e.message); }
}
console.log('# Netscape');
{
const loggedIn = [
'# Netscape HTTP Cookie File',
'# commentaire',
'',
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB'),
'#HttpOnly_' + T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PSID', 'CCC'),
T('.youtube.com', 'TRUE', '/', 'FALSE', 0, 'PREF', 'f6=40000000'),
T('.youtube.com', 'TRUE', '/', 'TRUE', PAST, 'GPS', '1'),
T('.example.com', 'TRUE', '/', 'FALSE', FUT, 'tracker', 'x'),
T('accounts.google.com', 'FALSE', '/', 'TRUE', FUT, '__Host-GAPS', 'g'),
'ligne cassée sans tabulation',
T('.youtube.com', 'MAYBE', '/', 'TRUE', FUT, 'X', 'y'),
].join('\r\n');
const r = cookies.parseCookies(loggedIn, { now: NOW });
const names = r.cookies.map(c => c.name);
check('CRLF + en-tête : cookies valides lus', names.includes('LOGIN_INFO') && names.includes('SAPISID') && names.includes('PREF'), names);
check('#HttpOnly_ reconnu', r.cookies.find(c => c.name === '__Secure-3PSID')?.httpOnly === true);
check('cookie de session (0) gardé', r.cookies.find(c => c.name === 'PREF')?.expires === 0);
check('expiré retiré', !names.includes('GPS'));
check('domaine étranger retiré', !names.includes('tracker'));
check('google.com gardé (hostOnly)', r.cookies.find(c => c.name === '__Host-GAPS')?.includeSubdomains === false);
check('avertissements (lignes, domaines, expirés)', r.warnings.length === 3, r.warnings);
const a = cookies.analyze(r.cookies);
check('analyze : loggedIn', a.loggedIn === true);
check('analyze : jamais de valeur', !JSON.stringify(a).includes('AAA') && !JSON.stringify(a).includes('BBB'));
check('analyze : authNames', a.authNames.includes('LOGIN_INFO') && a.authNames.includes('SAPISID') && a.authNames.includes('__Secure-3PSID'), a.authNames);
check('analyze : earliestAuthExpiry ISO', a.earliestAuthExpiry === new Date(FUT * 1000).toISOString());
// Sans en-tête, LF, valeur vide, tabulations converties en espaces
const noHeader = [
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'),
'.youtube.com TRUE / TRUE ' + FUT + ' __Secure-3PAPISID ZZZ',
T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'EMPTY', ''),
].join('\n');
const r2 = cookies.parseCookies(noHeader, { now: NOW });
check('sans en-tête + espaces au lieu de tabulations', r2.cookies.length === 3, r2.cookies.map(c => c.name));
check('valeur vide acceptée', r2.cookies.find(c => c.name === 'EMPTY')?.value === '');
check('loggedIn via __Secure-3PAPISID', cookies.analyze(r2.cookies).loggedIn === true);
// Règle yt-dlp : LOGIN_INFO seul ou SAPISID seul ne suffit pas
const onlySapisid = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, '__Secure-3PAPISID', 'x');
check('pas connecté sans LOGIN_INFO', cookies.analyze(cookies.parseCookies(onlySapisid, { now: NOW }).cookies).loggedIn === false);
const onlyLogin = T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x');
const aLogin = cookies.analyze(cookies.parseCookies(onlyLogin, { now: NOW }).cookies);
check('pas connecté sans SAPISID', aLogin.loggedIn === false && aLogin.missing.includes('SAPISID'));
// Cookies google.com seuls : ne comptent pas pour youtube.com
const googleOnly = [T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'x'), T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'x')].join('\n');
check('LOGIN_INFO sur google.com ne compte pas', cookies.analyze(cookies.parseCookies(googleOnly, { now: NOW }).cookies).loggedIn === false);
// Sérialisation : relue à l'identique, LF, en-tête
const text = cookies.serializeCookies(r.cookies);
check('sérialisation : en-tête Netscape + LF', text.startsWith('# Netscape HTTP Cookie File\n') && !text.includes('\r'));
const back = cookies.parseCookies(text, { now: NOW }).cookies;
check('sérialisation : aller-retour', JSON.stringify(back) === JSON.stringify(r.cookies));
}
console.log('# JSON');
{
const arr = [
{ domain: '.youtube.com', expirationDate: FUT + 0.512229, hostOnly: false, httpOnly: true, name: 'LOGIN_INFO', path: '/', sameSite: 'no_restriction', secure: true, session: false, storeId: '0', value: 'AAA', id: 1 },
{ domain: '.youtube.com', expirationDate: FUT + 0.9, hostOnly: false, httpOnly: false, name: 'SAPISID', path: '/', secure: true, session: false, value: 'BBB' },
{ domain: 'www.youtube.com', hostOnly: true, httpOnly: false, name: 'ST-xyz', path: '/', secure: false, session: true, value: 'S' },
{ domain: 'youtube.com', hostOnly: false, name: 'NODOT', path: '/', secure: true, expires: (FUT * 1000), value: 'ms' },
{ domain: '.youtube.com', name: 'OLD', expirationDate: PAST, value: 'x' },
{ domain: '.facebook.com', name: 'fb', expirationDate: FUT, value: 'x' },
{ nope: true },
];
const r = cookies.parseCookies(JSON.stringify(arr), { now: NOW });
const by = Object.fromEntries(r.cookies.map(c => [c.name, c]));
check('JSON : expirationDate décimal arrondi', by.LOGIN_INFO?.expires === FUT);
check('JSON : session -> 0', by['ST-xyz']?.expires === 0);
check('JSON : hostOnly -> sans sous-domaines', by['ST-xyz']?.includeSubdomains === false && by['ST-xyz']?.domain === 'www.youtube.com');
check('JSON : domaine non hostOnly sans point -> .youtube.com', by.NODOT?.domain === '.youtube.com' && by.NODOT?.includeSubdomains === true);
check('JSON : expires en millisecondes', by.NODOT?.expires === FUT);
check('JSON : httpOnly', by.LOGIN_INFO?.httpOnly === true && by.SAPISID?.httpOnly === false);
check('JSON : expiré, étranger et invalide retirés', !by.OLD && !by.fb && r.warnings.length === 3, r.warnings);
check('JSON : loggedIn', cookies.analyze(r.cookies).loggedIn === true);
check('JSON : hasSessionOnly faux (le cookie de session n\'est pas un cookie de connexion)', cookies.analyze(r.cookies).hasSessionOnly === false);
const wrapped = cookies.parseCookies(JSON.stringify({ url: 'https://www.youtube.com', cookies: arr.slice(0, 2) }), { now: NOW });
check('{cookies:[...]} accepté', wrapped.cookies.length === 2 && cookies.analyze(wrapped.cookies).loggedIn);
const withBom = cookies.parseCookies('' + JSON.stringify(arr.slice(0, 2)), { now: NOW });
check('BOM ignoré', withBom.cookies.length === 2);
const expiry = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'b', expiry: FUT }]), { now: NOW });
check('champ expiry (Selenium)', expiry.cookies[0].expires === FUT);
const sessionAuth = cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'LOGIN_INFO', value: 'b', session: true }]), { now: NOW });
check('hasSessionOnly vrai pour un cookie de connexion de session', cookies.analyze(sessionAuth.cookies).hasSessionOnly === true);
}
console.log('# Contenus invalides');
check('vide', throwsInvalid(() => cookies.parseCookies(' ')));
check('non-chaîne', throwsInvalid(() => cookies.parseCookies(null)));
check('texte quelconque', throwsInvalid(() => cookies.parseCookies('bonjour\nceci n\'est pas un fichier de cookies')));
check('JSON tronqué', throwsInvalid(() => cookies.parseCookies('[{"domain": ".youtube.com", "name": ')));
check('JSON objet sans cookies', throwsInvalid(() => cookies.parseCookies('{"a": 1}')));
check('JSON liste vide', throwsInvalid(() => cookies.parseCookies('[]')));
check('HTML', throwsInvalid(() => cookies.parseCookies('<html><body>Login</body></html>')));
check('commentaires seuls', throwsInvalid(() => cookies.parseCookies('# Netscape HTTP Cookie File\n# rien\n')));
check('valeur avec retour à la ligne refusée', throwsInvalid(() => cookies.parseCookies(JSON.stringify([{ domain: '.youtube.com', name: 'A', value: 'x\ny' }]))));
console.log('# Stockage');
const goodText = [T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'LOGIN_INFO', 'AAA'), T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'SAPISID', 'BBB')].join('\n');
let threw = false;
try { cookies.importCookies(T('.google.com', 'TRUE', '/', 'TRUE', FUT, 'SID', 'x')); } catch (e) { threw = e.code === 'INVALID_COOKIES'; }
check('import refusé sans cookie youtube.com', threw && !fs.existsSync(__glob.YOUTUBE_COOKIES));
const before = events;
const res = cookies.importCookies(goodText, { source: 'paste', by: { id: '42', username: 'raphix' } });
check('import : statut valide', res.status.valid && res.status.loggedIn && res.status.count === 2 && res.status.source === 'paste', res.status);
check('import : importedBy', res.status.importedBy?.username === 'raphix');
check('import : YOUTUBE_UPDATE émis', events > before);
check('import : fichier maître LF + en-tête', fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8').startsWith('# Netscape HTTP Cookie File\n'));
check('statut : jamais de valeur', !JSON.stringify(cookies.getStatus()).includes('AAA'));
const notLogged = cookies.importCookies(T('.youtube.com', 'TRUE', '/', 'TRUE', FUT, 'PREF', 'x'), { source: 'file' });
check('import non connecté : stocké + avertissement', notLogged.status.present && !notLogged.status.loggedIn && notLogged.warnings.some(w => /connexion/.test(w)), notLogged.warnings);
cookies.importCookies(goodText, { source: 'paste' });
check('markInvalid', cookies.markInvalid('test') === true && cookies.getStatus().valid === false && cookies.getStatus().invalidReason === 'test');
check('markInvalid idempotent', cookies.markInvalid('encore') === false && cookies.getStatus().invalidReason === 'test');
cookies.setLastCheck({ at: new Date().toISOString(), ok: true, loggedIn: true, premium: false, message: 'ok' });
check('vérification réussie : invalidation levée', cookies.getStatus().valid === true);
cookies.reload();
check('rechargement depuis le disque', cookies.getStatus().valid === true && cookies.getStatus().count === 2);
(async () => {
const copy = await cookies.acquireCopy();
check('acquireCopy : fichier identique au maître', fs.readFileSync(copy.path, 'utf8') === fs.readFileSync(__glob.YOUTUBE_COOKIES, 'utf8'));
check('acquireCopy : dossier chopin-ytc-', path.basename(path.dirname(copy.path)).startsWith('chopin-ytc-') && cookies.liveCopyCount() === 1);
copy.release(); copy.release();
check('release : dossier supprimé', !fs.existsSync(path.dirname(copy.path)) && cookies.liveCopyCount() === 0);
const del = cookies.deleteCookies();
check('deleteCookies', !del.present && !fs.existsSync(__glob.YOUTUBE_COOKIES));
check('acquireCopy sans cookies -> null', (await cookies.acquireCopy()) === null);
check('aucune copie temporaire restante', chopinTmpDirs().length === 0, chopinTmpDirs());
console.log('# Fichiers réels (lecture seule, analyse sans valeurs)');
const realTxt = path.join(BACKEND, 'data', 'cookies.txt');
const realJson = path.join(BACKEND, 'data', 'cookies.json');
const mtimes = () => [realTxt, realJson].map(f => fs.existsSync(f) ? fs.statSync(f).mtimeMs : null).join(',');
const mtimesBefore = mtimes();
for (const file of [realTxt, realJson]) {
if (!fs.existsSync(file)) { console.log(` (absent) ${path.basename(file)}`); continue; }
try {
const r = cookies.parseCookies(fs.readFileSync(file, 'utf8'));
console.log(` ${path.basename(file)} :`, JSON.stringify(cookies.analyze(r.cookies)), '| avertissements :', JSON.stringify(r.warnings));
} catch (e) {
console.log(` ${path.basename(file)} : erreur ${e.code} ${e.message}`);
}
}
// Migration : les anciens fichiers réels servent de source, la destination est le dossier isolé
cookies.reload();
__glob.COOKIES = realTxt;
__glob.COOKIES_JSON = realJson;
const mig = cookies.migrateLegacy();
const st = cookies.getStatus();
console.log(' migration :', JSON.stringify(mig), '| statut :', JSON.stringify({ present: st.present, loggedIn: st.loggedIn, valid: st.valid, source: st.source, count: st.count, authNames: st.authNames, missing: st.missing, earliestAuthExpiry: st.earliestAuthExpiry }));
check('migration : fichiers d\'origine non modifiés', mtimes() === mtimesBefore);
check('migration : pas de seconde migration', cookies.migrateLegacy().migrated === false);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})();
+113
View File
@@ -0,0 +1,113 @@
const { check, done, req } = require('./_setup');
const errors = req('src/youtube/errors');
const ID = 'ERROR: [youtube] jNQXAC9IVRw: ';
// Chaque ligne du tableau de research-ytdlp.md §5 (+ variantes réelles)
const CASES = [
// Bot check (apostrophe typographique et droite)
[ID + 'Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies.', 'BOT_CHECK'],
[ID + "Sign in to confirm you're not a bot. This helps protect our community. Learn more", 'BOT_CHECK'],
['ERROR: [youtube] abc: Sign in to confirm youre not a bot', 'BOT_CHECK'],
// IP bloquée
[ID + 'All player responses are invalid. Your IP is likely being blocked by Youtube', 'IP_BLOCKED'],
// Captcha
[ID + 'YouTube is requiring a captcha challenge before playback', 'CAPTCHA'],
// Rate limit
[ID + "This content isn't available, try again later. The current session has been rate-limited by YouTube for up to an hour. It is recommended to use `-t sleep` to add a delay between video requests", 'RATE_LIMITED'],
[ID + 'This content isn’t available, try again later.', 'RATE_LIMITED'],
// 403 au téléchargement
['ERROR: unable to download video data: HTTP Error 403: Forbidden', 'HTTP_403'],
['[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...', 'HTTP_403'],
['ERROR: [download] Giving up after 3 retries: HTTP Error 403: Forbidden', 'HTTP_403'],
// 429
['ERROR: [youtube] abc: Unable to download API page: HTTP Error 429: Too Many Requests', 'HTTP_429'],
// Âge
[ID + 'Sign in to confirm your age. This video may be inappropriate for some users.', 'AGE_RESTRICTED'],
[ID + 'This video is age-restricted and only available on YouTube', 'AGE_RESTRICTED'],
// Connexion / membres
[ID + 'Login details are needed to download this content', 'LOGIN_REQUIRED'],
[ID + "This video is available to this channel's members on level: Member (or any higher level). Join this channel to get access to members-only content like this video, and other exclusive perks.", 'MEMBERS_ONLY'],
[ID + 'Join this channel to get access to members-only content like this video, and other exclusive perks.', 'MEMBERS_ONLY'],
// Privée
[ID + "Private video. Sign in if you've been granted access to this video", 'PRIVATE'],
// Indisponible
[ID + 'Video unavailable', 'UNAVAILABLE'],
[ID + 'Video unavailable. This content isn’t available.', 'UNAVAILABLE'],
[ID + 'This video is unavailable', 'UNAVAILABLE'],
[ID + 'Video unavailable. This video has been removed by the uploader', 'UNAVAILABLE'],
// Géo
[ID + 'The uploader has not made this video available in your country', 'GEO_BLOCKED'],
// PO token (avertissement seul)
['WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with --extractor-args "youtube:po_token=mweb.gvs+XXX".', 'POT_MISSING'],
// SABR
['WARNING: [youtube] jNQXAC9IVRw: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment for your account.', 'SABR'],
['WARNING: [youtube] jNQXAC9IVRw: Some tv client https formats have been skipped as they are missing a url. YouTube may have enabled the SABR-only streaming experiment for the current session.', 'SABR'],
['[debug] [youtube] YouTube is forcing SABR streaming for this client.', 'SABR'],
// Défi JS
['WARNING: [youtube] jNQXAC9IVRw: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed.', 'JS_CHALLENGE'],
['WARNING: [youtube] jNQXAC9IVRw: Signature solving failed: Some formats may be missing.', 'JS_CHALLENGE'],
['WARNING: [youtube] No supported JavaScript runtime could be found. Only deno is enabled by default', 'JS_CHALLENGE'],
// Format
['ERROR: [youtube] jNQXAC9IVRw: Requested format is not available. Use --list-formats for a list of available formats', 'FORMAT_UNAVAILABLE'],
// Page à recharger
[ID + 'The page needs to be reloaded.', 'PAGE_RELOAD'],
// Cookies tournés
['WARNING: [youtube] The provided YouTube account cookies are no longer valid. They have likely been rotated in the browser as a security measure. For tips on how to effectively export YouTube cookies, refer to https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies .', 'COOKIES_ROTATED'],
// Fichier vide
['ERROR: The downloaded file is empty', 'EMPTY_DOWNLOAD'],
// Binaire absent / délai
['spawn yt-dlp ENOENT', 'BINARY_MISSING'],
['ERROR: yt-dlp introuvable (ENOENT)', 'BINARY_MISSING'],
['ERROR: [youtube] abc: Unable to download webpage: <urlopen error timed out> (caused by URLError(TimeoutError(\'timed out\')))', 'TIMEOUT'],
['aucune donnée après 20s', 'TIMEOUT'],
// Informations seules
['WARNING: [youtube] Skipping client "visionos" since it does not support cookies', 'UNKNOWN'],
['ERROR: Login with OAuth is no longer supported', 'UNKNOWN'],
['', 'UNKNOWN'],
];
console.log('# Tableau des messages');
for (const [text, expected] of CASES) {
const r = errors.classify(text);
check(`${expected.padEnd(18)} <- ${text.slice(0, 90)}`, r.cls === expected, r);
}
console.log('# Combinaisons (ligne par ligne)');
{
// L'ancien bug : un bloc contenant [youtube] était ignoré. Ici l'erreur est noyée dans du bruit.
const noisy = [
'[youtube] Extracting URL: https://www.youtube.com/watch?v=jNQXAC9IVRw',
'[youtube] jNQXAC9IVRw: Downloading webpage',
'WARNING: [youtube] jNQXAC9IVRw: mweb client https formats require a GVS PO Token which was not provided.',
'ERROR: [youtube] jNQXAC9IVRw: Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies for the authentication.',
].join('\r\n');
const r = errors.classify(noisy);
check('ERROR prime sur WARNING (BOT_CHECK, indice POT)', r.cls === 'BOT_CHECK' && r.hints.includes('POT_MISSING') && r.retry === 'route', r);
check('message sans préfixe ERROR:', r.message.startsWith('[youtube] jNQXAC9IVRw: Sign in'), r.message);
const rootCause = 'WARNING: [youtube] x: n challenge solving failed: Some formats may be missing.\nERROR: [youtube] x: Requested format is not available. Use --list-formats';
check('format indisponible + défi JS -> JS_CHALLENGE', errors.classify(rootCause).cls === 'JS_CHALLENGE');
const sabrEmpty = 'WARNING: [youtube] x: Some web client https formats have been skipped as they are missing a URL. YouTube may have enabled the SABR-only streaming experiment\nERROR: The downloaded file is empty';
check('fichier vide + SABR -> SABR', errors.classify(sabrEmpty).cls === 'SABR');
const rotatedBot = 'WARNING: [youtube] The provided YouTube account cookies are no longer valid.\nERROR: [youtube] x: Sign in to confirm you’re not a bot.';
const rb = errors.classify(rotatedBot);
check('rotation + bot : cls BOT_CHECK, drapeau cookiesRotated', rb.cls === 'BOT_CHECK' && rb.cookiesRotated === true, rb);
const rotatedOnly = errors.classify('WARNING: [youtube] The provided YouTube account cookies are no longer valid.');
check('rotation seule -> COOKIES_ROTATED', rotatedOnly.cls === 'COOKIES_ROTATED' && rotatedOnly.cookiesRotated, rotatedOnly);
const retry403 = '[download] Got error: HTTP Error 403: Forbidden. Retrying (1/3)...\n[download] Got error: HTTP Error 403: Forbidden. Retrying (2/3)...\nERROR: unable to download video data: HTTP Error 403: Forbidden';
check('403 avec réessais -> HTTP_403 (retry client)', errors.classify(retry403).cls === 'HTTP_403' && errors.classify(retry403).retry === 'client');
check('un WARNING citant "HTTP Error 403" n\'est pas une ligne d\'erreur', errors.isErrorLine('WARNING: [youtube] x: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403.') === false);
check('tableau de lignes accepté', errors.classify(['x', 'ERROR: Private video']).cls === 'PRIVATE');
check('chaque classe a une action', errors.CLASSES.every(c => ['route', 'cookies', 'client', 'none'].includes(errors.RETRY[c])));
}
console.log('# sanitize');
{
const s = errors.sanitize('[debug] Command-line config: [\'--cookies\', \'C:\\\\Temp\\\\chopin-ytc-AbC123\\\\cookies.txt\', \'--proxy\', \'http://user:secret@warp:1080\'] --cookies /tmp/chopin-ytc-x1/cookies.txt Cookie: SID=abcdef; HSID=zzz Authorization: SAPISIDHASH 123_abc SAPISID=VALUE123 __Secure-3PSID=SECRETVALUE po_token=mweb.gvs+AAAAAAAAAAAA');
check('aucun secret', !/secret|abcdef|VALUE123|SECRETVALUE|AAAAAAAAAAAA|123_abc|AbC123|x1\//.test(s), s);
check('proxy masqué', s.includes('http://***@warp:1080'), s);
check('texte normal inchangé', errors.sanitize('ERROR: [youtube] abc: Video unavailable') === 'ERROR: [youtube] abc: Video unavailable');
check('null -> ""', errors.sanitize(null) === '');
}
done();
+257
View File
@@ -0,0 +1,257 @@
// Test du flux Google (OAuth2.js + YoutubeList.js + PlaylistManager.js) avec googleapis, la base de données,
// la configuration, les utilisateurs et loguix simulés : aucun fichier de data/ n'est lu ni écrit, aucun réseau.
const assert = require("assert")
const Module = require("module")
const EventEmitter = require("events")
const path = require("path")
const SRC = require("path").resolve(__dirname, "../../src")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
// --- Simulations ---
const cfg = { id: "client-id", secret: "client-secret", website: "https://chopin.example/" }
const databases = {}
class FakeDatabase {
constructor(name, file, empty) { this.name = name; this.path = file; this.data = JSON.parse(JSON.stringify(empty || {})); this.saves = 0; databases[name] = this }
save() { this.saves++ }
}
const fakeLog = { LogType: class { log() {} warn() {} error() {} }, getInstance: () => ({ log() {}, warn() {}, error() {} }) }
const api = { calls: { playlists: 0, items: 0, videos: 0, channels: 0 }, failWith: null, revoked: [] }
const PLAYLISTS = [
{ id: "PLa", snippet: { title: "Rock", channelTitle: "Moi", channelId: "UCme", thumbnails: { default: { url: "t-a" } } }, contentDetails: { itemCount: 120 } },
{ id: "PLb", snippet: { title: "Calme", channelTitle: "Moi", channelId: "UCme" }, contentDetails: { itemCount: 3 } },
{ id: "PLc", snippet: { title: "Vide", channelTitle: "Moi", channelId: "UCme", thumbnails: null }, contentDetails: { itemCount: 0 } },
]
function itemsFor(listId) {
const count = { PLa: 120, PLb: 3, PLc: 0, LLme: 60 }[listId] ?? 0
return Array.from({ length: count }, (_, i) => {
// LL réutilise les vidéos de PLa (cache des durées partagé)
const vid = (listId === "LLme" ? "PLa" : listId) + "-v" + i
const priv = listId === "PLb" && i === 1
return {
snippet: {
title: priv ? "Private video" : "Titre " + vid,
resourceId: { videoId: vid },
videoOwnerChannelTitle: priv ? undefined : "Artiste",
videoOwnerChannelId: priv ? undefined : "UCart",
thumbnails: { medium: { url: "m-" + vid } }
},
contentDetails: { videoId: vid }
}
})
}
function page(all, token, size) {
const start = token ? Number(token) : 0
return { items: all.slice(start, start + size), nextPageToken: start + size < all.length ? String(start + size) : undefined }
}
function maybeFail() { if (api.failWith) throw api.failWith }
class FakeOAuth2 extends EventEmitter {
constructor(id, secret, uri) { super(); this.args = [id, secret, uri]; this.credentials = null }
generateAuthUrl(opts) { this.lastOpts = opts; return "https://accounts.google.com/o/oauth2/v2/auth?" + new URLSearchParams({ ...opts, scope: opts.scope.join(" ") }) }
async getToken(code) {
if (code === "bad") { const e = new Error("invalid_grant"); e.response = { data: { error: "invalid_grant" } }; throw e }
return { tokens: { access_token: "AT-" + code, refresh_token: "RT-" + code, expiry_date: 123, scope: "yt" } }
}
setCredentials(t) { this.credentials = t }
async revokeToken(t) { api.revoked.push(t); return {} }
}
const fakeGoogle = {
google: {
auth: { OAuth2: FakeOAuth2 },
youtube: ({ auth }) => {
assert.ok(auth instanceof FakeOAuth2, "service authentifié")
return {
channels: { list: async () => { api.calls.channels++; return { data: { items: [{ id: "UCme", snippet: { title: "Ma chaîne" }, contentDetails: { relatedPlaylists: { likes: "LLme" } } }] } } } },
playlists: { list: async (p) => {
api.calls.playlists++; maybeFail()
if (p.id) return { data: { items: PLAYLISTS.filter(x => p.id.includes(x.id)) } }
assert.strictEqual(p.mine, true)
return { data: page(PLAYLISTS, p.pageToken, 2) }
} },
playlistItems: { list: async (p) => {
api.calls.items++; maybeFail()
assert.strictEqual(p.maxResults, 50)
return { data: page(itemsFor(p.playlistId), p.pageToken, 50) }
} },
videos: { list: async (p) => {
api.calls.videos++; maybeFail()
assert.ok(p.id.length <= 50, "50 ids max par appel")
return { data: { items: p.id.map((id, i) => ({ id, contentDetails: { duration: "PT" + (i % 5) + "M" + (i % 60) + "S" } })) } }
} },
}
}
}
}
const stubs = {
"googleapis": fakeGoogle,
"loguix": fakeLog,
[norm(SRC + "/utils/Database/Database")]: { Database: FakeDatabase, getDatabase: (n) => databases[n] },
[norm(SRC + "/utils/Database/Configuration")]: { getYoutubeApiClientId: () => cfg.id, getYoutubeApiClientSecret: () => cfg.secret, getWebsiteLink: () => cfg.website },
[norm(SRC + "/server/auth/User")]: { getUserById: (id) => (id ? { identity: { id } } : null) },
[norm(SRC + "/player/Finder")]: { search: async () => null },
[norm(SRC + "/media/SpotifyInformation")]: { getTracks: async (p) => p },
[norm(SRC + "/utils/SafeMetric")]: { toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? Math.floor(n) : 0 } },
}
const origLoad = Module._load
Module._load = function (request, parent, isMain) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const events = []
process.on("GOOGLE_UPDATE", (u) => events.push("GOOGLE_UPDATE:" + u))
process.on("PLAYLISTS_UPDATE", (u) => events.push("PLAYLISTS_UPDATE:" + u))
const OAuth2 = require(SRC + "/playlists/Google/OAuth2.js")
const PM = require(SRC + "/playlists/PlaylistManager.js")
const store = databases.google
assert.ok(store && store.path.endsWith("google.json"), "base google.json")
;(async () => {
// --- Configuration absente : bouton désactivé ---
cfg.id = ""
assert.strictEqual(OAuth2.getStatus("u1").configured, false)
assert.strictEqual(OAuth2.createAuthUrl("u1"), null)
cfg.id = "client-id"
// --- URL de consentement ---
const url = new URL(OAuth2.createAuthUrl("u1"))
const state = url.searchParams.get("state")
assert.strictEqual(url.searchParams.get("prompt"), "consent")
assert.strictEqual(url.searchParams.get("access_type"), "offline")
assert.match(state, /^[0-9a-f]{48}$/)
// --- Liaison : state d'un autre utilisateur / invalide refusé ---
await assert.rejects(OAuth2.linkAccount("u2", "code1", state), { code: "INVALID_STATE" })
await assert.rejects(OAuth2.linkAccount("u1", "code1", "nope"), { code: "INVALID_STATE" })
await assert.rejects(OAuth2.linkAccount("u1", "", state), { code: "INVALID_PARAMS" })
const status = await OAuth2.linkAccount("u1", "code1", state)
assert.deepStrictEqual([status.linked, status.valid, status.channel.title], [true, true, "Ma chaîne"])
assert.strictEqual(store.data.u1.tokens.refresh_token, "RT-code1")
assert.strictEqual(store.data.u1.likesPlaylistId, "LLme")
await assert.rejects(OAuth2.linkAccount("u1", "code1", state), { code: "INVALID_STATE" }, "state à usage unique")
assert.ok(!JSON.stringify(OAuth2.getStatus("u1")).includes("RT-"), "aucun jeton dans le statut")
// Redirect URI = site + /oauth2callback (slash final retiré)
const client = OAuth2.getClient("u1")
assert.strictEqual(client.args[2], "https://chopin.example/oauth2callback")
assert.strictEqual(OAuth2.getClient("u1"), client, "client en cache")
// --- Jetons rafraîchis par la librairie : persistés, refresh_token conservé ---
client.emit("tokens", { access_token: "AT-new", expiry_date: 999 })
assert.deepStrictEqual([store.data.u1.tokens.access_token, store.data.u1.tokens.refresh_token], ["AT-new", "RT-code1"])
// --- CONFIG_UPDATE : client recréé (identifiants relus) ---
cfg.secret = "secret-2"
process.emit("CONFIG_UPDATE")
const client2 = OAuth2.getClient("u1")
assert.notStrictEqual(client2, client)
assert.strictEqual(client2.args[1], "secret-2")
assert.strictEqual(client2.credentials.access_token, "AT-new")
// --- Synchronisation (+ concurrence) ---
const p1 = PM.syncYoutube("u1", { force: true })
assert.strictEqual(OAuth2.getStatus("u1").syncing, true)
const p2 = await PM.syncYoutube("u1", { force: true })
assert.strictEqual(p2.error, "ALREADY_SYNCING")
const r1 = await p1
assert.strictEqual(r1.ok, true, JSON.stringify(r1))
assert.strictEqual(r1.playlists, 4, "3 playlists + J'aime")
const list = PM.getPlaylistsOfUser("u1")
assert.strictEqual(list.length, 4)
const byYt = Object.fromEntries(list.map(p => [p.youtubeId, p]))
assert.strictEqual(byYt.PLa.songs.length, 120, "pagination 50+50+20")
assert.strictEqual(byYt.PLb.songs.length, 2, "vidéo privée ignorée")
assert.strictEqual(byYt.PLc.songs.length, 0)
assert.strictEqual(byYt.LLme.title, "Vidéos « J'aime »")
for (const p of list) {
assert.strictEqual(typeof p.playlistId, "string")
assert.strictEqual(p.type, "youtube")
assert.strictEqual(p.source, "google")
assert.ok(Number.isFinite(p.duration))
for (const s of p.songs) {
assert.strictEqual(s.type, "youtube")
assert.ok(Number.isFinite(s.duration))
assert.ok(s.readduration)
}
}
assert.strictEqual(new Set(list.map(p => p.playlistId)).size, 4, "identifiants uniques")
assert.strictEqual(byYt.PLa.songs[1].duration, 61, "durée ISO-8601 : PT1M1S")
assert.strictEqual(byYt.PLc.thumbnail, "https://radomisol.fr/wp-content/uploads/2016/08/cropped-note-radomisol-musique.png", "miniature absente tolérée")
// 2 pages de playlists ; durées : J'aime (60 → 2 appels), PLa (60 restantes grâce au cache → 2 appels), PLb (1 appel) = 5
assert.strictEqual(api.calls.playlists, 2)
assert.strictEqual(api.calls.videos, 5)
assert.ok(events.includes("PLAYLISTS_UPDATE:u1"))
assert.strictEqual(OAuth2.getStatus("u1").syncing, false)
assert.strictEqual(OAuth2.getStatus("u1").lastSync.ok, true)
// --- Délai entre deux synchronisations, puis resynchronisation : identifiants et ordre conservés ---
assert.strictEqual((await PM.syncYoutube("u1")).error, "COOLDOWN")
const ids = list.map(p => p.playlistId).join(",")
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).ok, true)
assert.strictEqual(PM.getPlaylistsOfUser("u1").map(p => p.playlistId).join(","), ids, "même ordre, mêmes ids")
// --- Lecture par identifiant (chaîne ou nombre) ---
const anyId = PM.getPlaylistsOfUser("u1")[0].playlistId
assert.ok(PM.getPlaylistOfUser("u1", anyId))
assert.ok(PM.getPlaylistOfUser("u1", Number(anyId)))
// --- Rafraîchissement d'une playlist importée via l'API (J'aime) ---
const likesId = PM.getPlaylistsOfUser("u1").find(p => p.youtubeId === "LLme").playlistId
const before = api.calls.items
const refreshed = await PM.refreshPlaylist("u1", likesId)
assert.ok(refreshed && refreshed.playlistId === likesId && refreshed.songs.length === 60)
assert.ok(api.calls.items > before, "rafraîchie via playlistItems.list")
// --- Création classique : identifiant primitif (X1) + copie indépendante (X5) ---
const created = await PM.addPlaylist("u1", "Ma liste", null, "moi", "u1", "av")
assert.strictEqual(typeof created.playlistId, "string")
assert.ok(PM.getPlaylistOfUser("u1", created.playlistId), "retrouvée immédiatement")
const copy = PM.copyPlaylist("u1", "u2", created.playlistId)
assert.ok(copy && copy !== created)
assert.strictEqual(PM.copyPlaylist("u1", "u2", created.playlistId), null, "doublon de titre refusé")
PM.renamePlaylist("u2", copy.playlistId, "Autre")
assert.strictEqual(created.title, "Ma liste", "copie indépendante")
// --- Autorisation révoquée chez Google : compte invalide ---
const err = new Error("invalid_grant"); err.response = { status: 400, data: { error: "invalid_grant" } }
api.failWith = err
const failed = await PM.syncYoutube("u1", { force: true })
api.failWith = null
assert.strictEqual(failed.error, "AUTH_EXPIRED")
const st = OAuth2.getStatus("u1")
assert.deepStrictEqual([st.linked, st.valid, st.lastSync.ok], [true, false, false])
assert.strictEqual(OAuth2.getClient("u1"), null)
assert.strictEqual((await PM.syncYoutube("u1", { force: true })).error, "NOT_LINKED")
// --- Reconnexion puis quota épuisé ---
const url2 = new URL(OAuth2.createAuthUrl("u1"))
await OAuth2.linkAccount("u1", "code2", url2.searchParams.get("state"))
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "reconnexion")
const q = new Error("quota"); q.errors = [{ reason: "quotaExceeded" }]; q.response = { status: 403, data: {} }
api.failWith = q
const quota = await PM.syncYoutube("u1", { force: true })
api.failWith = null
assert.strictEqual(quota.error, "QUOTA")
assert.strictEqual(OAuth2.getStatus("u1").valid, true, "quota : compte toujours valide")
// --- Code invalide ---
const url3 = new URL(OAuth2.createAuthUrl("u3"))
await assert.rejects(OAuth2.linkAccount("u3", "bad", url3.searchParams.get("state")), { code: "TOKEN_ERROR" })
// --- Déconnexion : suppression + révocation ---
const res = await OAuth2.unlink("u1")
assert.deepStrictEqual(res, { removed: true, revoked: true })
assert.deepStrictEqual(api.revoked, ["RT-code2"])
assert.strictEqual(store.data.u1, undefined)
assert.strictEqual(OAuth2.getStatus("u1").linked, false)
assert.ok(PM.getPlaylistsOfUser("u1").length >= 4, "playlists importées conservées")
assert.deepStrictEqual(await OAuth2.unlink("u1"), { removed: false, revoked: false })
assert.ok(events.filter(e => e === "GOOGLE_UPDATE:u1").length >= 4)
console.log("google-flow.test.js : OK", JSON.stringify(api.calls))
})().catch((e) => { console.error(e); process.exit(1) })
+84
View File
@@ -0,0 +1,84 @@
// Tests des fonctions pures de backend/src/playlists/Google/Utils.js (aucune dépendance)
const assert = require("assert")
const U = require(require("path").resolve(__dirname, "../../src/playlists/Google/Utils.js"))
;(async () => {
// --- parseIsoDuration ---
const cases = [["PT1H2M3S", 3723], ["PT4M13S", 253], ["PT45S", 45], ["PT0S", 0], ["P0D", 0], ["P1DT2H", 93600],
["PT10M", 600], ["P1W", 604800], ["PT1.5S", 1], ["pt3m", 180], ["P", null], ["PT", null], ["", null],
["1:23", null], [null, null], [123, null], ["P1Y", null], ["PT1H2M3", null]]
for (const [input, expected] of cases) assert.strictEqual(U.parseIsoDuration(input), expected, "parseIsoDuration(" + input + ")")
// --- collectPages : pagination simulée (3 pages de 50 + 1 de 20 = 170) ---
const makeFetcher = (total, size, log) => async (token) => {
log.push(token)
const start = token ? Number(token) : 0
const items = Array.from({ length: Math.min(size, total - start) }, (_, i) => start + i)
const next = start + size < total ? String(start + size) : undefined
return { items, nextPageToken: next }
}
let log = []
let r = await U.collectPages(makeFetcher(170, 50, log), { maxItems: 500 })
assert.strictEqual(r.items.length, 170); assert.strictEqual(r.truncated, false); assert.strictEqual(r.pages, 4)
assert.deepStrictEqual(log, [undefined, "50", "100", "150"])
// limite atteinte au milieu d'une page
log = []
r = await U.collectPages(makeFetcher(1000, 50, log), { maxItems: 120 })
assert.strictEqual(r.items.length, 120); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 3)
assert.deepStrictEqual(r.items.slice(-1), [119])
// limite atteinte pile en fin de page alors que Google annonce encore une page
r = await U.collectPages(makeFetcher(1000, 50, []), { maxItems: 100 })
assert.strictEqual(r.items.length, 100); assert.strictEqual(r.truncated, true); assert.strictEqual(r.pages, 2)
// limite pile sur le total : pas tronqué
r = await U.collectPages(makeFetcher(100, 50, []), { maxItems: 100 })
assert.strictEqual(r.truncated, false); assert.strictEqual(r.items.length, 100)
// jeton répété : pas de boucle infinie
let calls = 0
r = await U.collectPages(async () => { calls++; return { items: [calls], nextPageToken: "SAME" } }, { maxItems: 500 })
assert.strictEqual(calls, 2); assert.strictEqual(r.items.length, 2)
// maxPages
r = await U.collectPages(makeFetcher(1000, 10, []), { maxItems: 500, maxPages: 3 })
assert.strictEqual(r.items.length, 30); assert.strictEqual(r.truncated, true)
// page vide / réponse inattendue
r = await U.collectPages(async () => null)
assert.deepStrictEqual(r.items, [])
// erreur propagée
await assert.rejects(U.collectPages(async () => { throw new Error("boom") }), /boom/)
// --- chunk ---
assert.deepStrictEqual(U.chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]])
assert.strictEqual(U.chunk(Array.from({ length: 120 }), 50).length, 3)
assert.deepStrictEqual(U.chunk([], 50), [])
// --- createStateStore (horloge simulée) ---
let now = 1_000_000
const store = U.createStateStore({ ttlMs: 10 * 60 * 1000, maxPerKey: 3, now: () => now })
const s1 = store.create("alice")
assert.match(s1, /^[0-9a-f]{48}$/)
assert.strictEqual(store.consume(s1, "bob"), false, "autre utilisateur refusé")
assert.strictEqual(store.consume(s1, "alice"), true, "propriétaire accepté")
assert.strictEqual(store.consume(s1, "alice"), false, "usage unique")
const s2 = store.create("alice")
now += 10 * 60 * 1000 + 1
assert.strictEqual(store.consume(s2, "alice"), false, "expiré après 10 min")
const s3 = store.create("alice")
now += 9 * 60 * 1000
assert.strictEqual(store.consume(s3, "alice"), true, "valide avant 10 min")
// maxPerKey : les plus anciens sont évincés
const olds = [store.create("carol"), store.create("carol"), store.create("carol"), store.create("carol")]
assert.strictEqual(store.consume(olds[0], "carol"), false, "plus ancien évincé")
assert.strictEqual(store.consume(olds[3], "carol"), true)
store.clear("carol")
assert.strictEqual(store.consume(olds[2], "carol"), false, "clear")
assert.strictEqual(store.consume(undefined, "carol"), false)
assert.strictEqual(store.consume(12, "carol"), false)
// --- pickTokens / bestThumbnail ---
assert.deepStrictEqual(U.pickTokens({ access_token: "a", refresh_token: null, expiry_date: 5, foo: 1 }), { access_token: "a", expiry_date: 5 })
assert.deepStrictEqual(U.pickTokens(null), {})
assert.strictEqual(U.bestThumbnail({ default: { url: "d" }, high: { url: "h" } }), "h")
assert.strictEqual(U.bestThumbnail({}), null)
assert.strictEqual(U.bestThumbnail(undefined), null)
console.log("google-utils.test.js : OK")
})().catch((e) => { console.error(e); process.exit(1) })
+120
View File
@@ -0,0 +1,120 @@
// Tests unitaires de backend/src/utils/Limiter.js (node limiter.test.js)
const assert = require("assert");
const path = require("path").resolve(__dirname, "../../src/utils/Limiter.js");
const { createLimiter, mapLimit, createInflight, createCache } = require(path);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
test("limiter: concurrence max respectée et résultats corrects", async () => {
const lim = createLimiter(2);
let active = 0, peak = 0;
const res = await Promise.all([1, 2, 3, 4, 5, 6].map(i => lim.run(async () => {
active++; peak = Math.max(peak, active);
await sleep(20);
active--;
return i * 10;
})));
assert.deepStrictEqual(res, [10, 20, 30, 40, 50, 60]);
assert.strictEqual(peak, 2);
assert.strictEqual(lim.active, 0);
assert.strictEqual(lim.pending, 0);
});
test("limiter: erreurs (async et sync) propagées sans bloquer la file", async () => {
const lim = createLimiter(1);
await assert.rejects(lim.run(async () => { throw new Error("a"); }), /a/);
await assert.rejects(lim.run(() => { throw new Error("b"); }), /b/);
assert.strictEqual(await lim.run(() => 42), 42);
});
test("limiter: minIntervalMs espace les démarrages", async () => {
const lim = createLimiter(5, { minIntervalMs: 30 });
const starts = [];
await Promise.all([0, 1, 2, 3].map(() => lim.run(async () => { starts.push(Date.now()); })));
for (let i = 1; i < starts.length; i++) assert.ok(starts[i] - starts[i - 1] >= 25, "écart " + (starts[i] - starts[i - 1]));
});
test("mapLimit: ordre conservé, concurrence bornée, erreurs ignorées", async () => {
let active = 0, peak = 0;
const out = await mapLimit([30, 5, 20, 1, 10], 3, async (ms, i) => {
active++; peak = Math.max(peak, active);
await sleep(ms);
active--;
if (i === 2) throw new Error("x");
return ms;
}, () => null);
assert.deepStrictEqual(out, [30, 5, null, 1, 10]);
assert.strictEqual(peak, 3);
assert.deepStrictEqual(await mapLimit(null, 2, async x => x), []);
});
test("inflight: appels simultanés dédupliqués, libérés ensuite", async () => {
const inf = createInflight();
let calls = 0;
const fn = async () => { calls++; await sleep(10); return "v"; };
const [a, b] = await Promise.all([inf.run("k", fn), inf.run("k", fn)]);
assert.strictEqual(a, "v"); assert.strictEqual(b, "v"); assert.strictEqual(calls, 1);
assert.strictEqual(inf.size, 0);
await inf.run("k", fn);
assert.strictEqual(calls, 2);
await assert.rejects(inf.run("e", async () => { throw new Error("boom"); }));
assert.strictEqual(inf.size, 0);
});
test("cache: TTL, null explicite, expiration", async () => {
const c = createCache({ ttlMs: 40, max: 10 });
c.set("a", 1);
c.set("n", null);
assert.strictEqual(c.get("a"), 1);
assert.strictEqual(c.get("n"), null);
assert.strictEqual(c.get("absent"), undefined);
c.set("z", 1, 0); // TTL 0 → pas mis en cache
assert.strictEqual(c.get("z"), undefined);
await sleep(60);
assert.strictEqual(c.get("a"), undefined);
assert.strictEqual(c.size, 1); // "n" expiré mais pas encore lu
});
test("cache: taille max et éviction LRU", () => {
const c = createCache({ ttlMs: 10000, max: 3 });
c.set("a", 1); c.set("b", 2); c.set("c", 3);
c.get("a"); // a devient le plus récent
c.set("d", 4);
assert.strictEqual(c.size, 3);
assert.strictEqual(c.get("b"), undefined);
assert.strictEqual(c.get("a"), 1);
assert.strictEqual(c.get("d"), 4);
});
test("cache.wrap: hit, dédup en cours, null non mis en cache, ttlFor", async () => {
const c = createCache({ ttlMs: 10000, max: 10 });
let calls = 0;
const fn = async () => { calls++; await sleep(10); return { x: 1 }; };
const [a, b] = await Promise.all([c.wrap("k", fn), c.wrap("k", fn)]);
assert.strictEqual(a, b); assert.strictEqual(calls, 1);
await c.wrap("k", fn);
assert.strictEqual(calls, 1);
let nulls = 0;
await c.wrap("n", async () => { nulls++; return null; });
await c.wrap("n", async () => { nulls++; return null; });
assert.strictEqual(nulls, 2);
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
await c.wrap("m", async () => { nulls++; return null; }, { ttlFor: v => v === null ? 5000 : 10000 });
assert.strictEqual(nulls, 3);
await assert.rejects(c.wrap("e", async () => { throw new Error("err"); }));
assert.strictEqual(c.get("e"), undefined);
const s = c.stats();
assert.ok(s.hits >= 2 && s.misses >= 4, JSON.stringify(s));
});
(async () => {
let failed = 0;
for (const t of tests) {
try { await t.fn(); console.log("OK " + t.name); }
catch (e) { failed++; console.log("FAIL " + t.name + "\n " + (e && e.stack || e)); }
}
console.log(`\n${tests.length - failed}/${tests.length} réussis`);
process.exit(failed ? 1 : 0);
})();
+107
View File
@@ -0,0 +1,107 @@
// Test réseau de YoutubeInformation / SpotifyInformation / Finder (cache + limiteur), sans démarrer le bot.
const assert = require("assert");
const path = require("path");
const SRC = require("path").resolve(__dirname, "../../src");
// Logs loguix dans le scratchpad (sinon LogType.error plante : flux non initialisé)
require(path.join(SRC, "../node_modules/loguix")).setup(path.join(__dirname, "logs"), path.join(SRC, "../package.json"));
// Configuration factice : évite de lire data/config.json (et son process.exit sans token)
const confPath = require.resolve(path.join(SRC, "utils/Database/Configuration.js"));
const fakeConf = { id: "", secret: "" };
require.cache[confPath] = { id: confPath, filename: confPath, loaded: true, exports: {
getSpotifyClientId: () => fakeConf.id,
getSpotifyClientSecret: () => fakeConf.secret,
} };
const yt = require(path.join(SRC, "media/YoutubeInformation.js"));
const spotify = require(path.join(SRC, "media/SpotifyInformation.js"));
const Finder = require(path.join(SRC, "player/Finder.js"));
const { Playlist } = require(path.join(SRC, "playlists/Playlist.js"));
async function timed(label, fn) {
const t = Date.now();
const r = await fn();
const ms = Date.now() - t;
console.log(`${label}: ${ms} ms`);
return [r, ms];
}
(async () => {
// 1) Recherche : 2e appel servi par le cache
const [s1, m1] = await timed("getQuery #1 (réseau)", () => yt.getQuery("Daft Punk Around the World", true));
assert.ok(Array.isArray(s1) && s1.length > 0, "résultats attendus");
const [s2, m2] = await timed("getQuery #2 (cache)", () => yt.getQuery(" daft punk around the world ", false));
assert.ok(s2 && s2.url === s1[0].url, "même premier résultat");
assert.ok(m2 < 20, "le 2e appel doit venir du cache");
assert.notStrictEqual(s2, s1[0], "instances Song distinctes");
// 2) Vidéo par id : 2e appel + getSecondsFromUrl servis par le cache
const url = "https://www.youtube.com/watch?v=jNQXAC9IVRw";
const [v1] = await timed("getVideo #1 (réseau)", () => yt.getVideo(url));
assert.ok(v1 && v1.id === "jNQXAC9IVRw" && v1.duration > 0, JSON.stringify(v1));
const [v2, mv2] = await timed("getVideo #2 (cache)", () => yt.getVideo(url));
assert.ok(mv2 < 20 && v2.title === v1.title);
const [secs, ms3] = await timed("getSecondsFromUrl (cache)", () => yt.getSecondsFromUrl("https://youtu.be/jNQXAC9IVRw"));
assert.strictEqual(secs, v1.duration); assert.ok(ms3 < 20);
// 3) Appels simultanés dédupliqués (1 seul scrape)
const [[a, b], md] = await timed("2 recherches simultanées identiques", () => Promise.all([yt.getQuery("lofi hip hop radio"), yt.getQuery("LOFI hip hop radio")]));
assert.ok(a && b && a.url === b.url);
// 4) Durée d'une vidéo issue d'une recherche : déjà en cache
const [secs2, ms4] = await timed("getSecondsFromUrl d'un résultat de recherche", () => yt.getSecondsFromUrl(s1[1].url));
assert.ok(ms4 < 20 && secs2 === s1[1].duration);
// 5) Playlists YouTube (nouvelle mise en page) + avatar ; 2e appel servi par le cache
for (const listUrl of ["https://www.youtube.com/playlist?list=PLdXJrX9OsbOVq2TKvUc3xGUp2Rm8nTUVh", "https://www.youtube.com/watch?v=fcnDmrtj6Sk&list=PL4fGSI1pDJn6puJdseH2Rt9sMvt9E2M4i"]) {
const [p1] = await timed("getPlaylist #1 (réseau)", () => yt.getPlaylist(listUrl));
assert.ok(p1 && p1.songs.length > 0, "playlist attendue");
const s0 = p1.songs[0];
console.log(" playlist:", p1.title, "| auteur:", p1.author, p1.authorId, "| titres:", p1.songs.length, "| durée:", p1.readduration, "| avatar:", String(p1.authorAvatar).slice(0, 50));
console.log(" 1er titre:", s0.title, "|", s0.author, s0.authorId, "|", s0.readduration, s0.url);
assert.ok(s0.url.startsWith("https://www.youtube.com/watch?v=") && s0.type === "youtube" && s0.duration > 0);
const [p2, mp2] = await timed("getPlaylist #2 (cache)", () => yt.getPlaylist(listUrl));
assert.ok(mp2 < 20 && p2.songs.length === p1.songs.length && p2.authorAvatar === p1.authorAvatar);
const [sec, ms5] = await timed("getSecondsFromUrl d'un titre de playlist", () => yt.getSecondsFromUrl(s0.url));
assert.ok(ms5 < 20 && sec === s0.duration);
}
// Avatar d'une chaîne (en-têtes navigateur + consentement), 2e appel en cache
// 6) getTracks : concurrence 3, ordre conservé, titres invalides ignorés, idempotent
const pl = new Playlist("Test", "https://open.spotify.com/playlist/x");
pl.type = "spotify";
pl.songs = [
{ id: "t1", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 248000 },
null,
{ id: "t2", name: "Bohemian Rhapsody", artists: [{ name: "Queen" }], duration_ms: 354000 },
{ id: "pod", name: "Episode", artists: [] },
{ id: "t3", name: "Billie Jean", artists: [{ name: "Michael Jackson" }], duration_ms: 294000 },
];
const [out] = await timed("getTracks (3 titres)", () => spotify.getTracks(pl));
assert.strictEqual(out, pl);
assert.deepStrictEqual(out.songs.map(s => s.id), ["t1", "t2", "t3"]);
assert.ok(out.songs.every(s => s.type === "youtube" && s.url.startsWith("https://www.youtube.com/watch?v=")));
assert.strictEqual(out.duration, 248 + 354 + 294);
const [, mi] = await timed("getTracks idempotent", () => spotify.getTracks(pl));
assert.ok(mi < 20 && pl.songs.length === 3);
// 7) Finder : playlist Spotify convertie (spotify.getPlaylist simulé), échec total → null
const realGetPlaylist = spotify.getPlaylist;
spotify.getPlaylist = async () => { const p = new Playlist("Sim", "u"); p.type = "spotify"; p.songs = [{ id: "z", name: "Get Lucky", artists: [{ name: "Daft Punk" }], duration_ms: 1000 }]; return p; };
const fp = await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true);
assert.ok(fp instanceof Playlist && fp.songs[0].type === "youtube" && fp.songs[0].url, "Finder doit renvoyer des Song convertis");
const fp2 = await Finder.search("https://open.spotify.com/intl-fr/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc", false, "PLAYLIST");
assert.ok(fp2 && fp2.songs[0].url);
spotify.getPlaylist = async () => { const p = new Playlist("Vide", "u"); p.type = "spotify"; p.songs = [null]; return p; };
assert.strictEqual(await Finder.search("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", true), null);
spotify.getPlaylist = realGetPlaylist;
console.log("Finder Spotify: OK");
// 8) Spotify sans identifiants : erreur propre (pas d'appel réseau), lecture paresseuse
assert.strictEqual(await spotify.getSong("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC"), null);
process.emit("CONFIG_UPDATE");
console.log("Spotify sans identifiants: OK (null)");
console.log("\nTOUS LES TESTS RÉUSSIS");
process.exit(0);
})().catch(e => { console.error("ÉCHEC", e); process.exit(1); });
+113
View File
@@ -0,0 +1,113 @@
const { check, done, req } = require('./_setup');
const plan = req('src/youtube/plan');
const W = 'http://warp:1080';
const ROUTES = { 'warp+direct': [W, null], 'direct': [null], 'warp seul': [W] };
const FB = 'web_safari,tv_downgraded';
const label = (a) => `${a.proxy ? 'W' : 'D'}/${a.identity === 'cookies' ? 'C' : 'A'}${a.clients ? '/fb' : ''}${a.seek ? '/' + a.seek : ''}`;
/**
* Simule getStream : chaque tentative échoue avec la classe donnée (tableau, ou fonction (attempt, i, history))
* okAt : index de la tentative qui réussit ; rotateAt : la tentative i invalide les cookies
*/
function simulate({ routes, mode = 'fallback', cookiesValid = true, cookiesOnProxy = false, fail, okAt = -1, rotateAt = -1, seekMode = null, fallbackClients = FB }) {
const history = [];
const seq = [];
let valid = cookiesValid;
for (let i = 0; i < 10; i++) {
const a = plan.planNext({ routes, mode, cookiesValid: valid, cookiesOnProxy, clients: '', fallbackClients, seekMode, history, maxAttempts: 4 });
if (!a) break;
seq.push(label(a));
if (i === okAt) { seq.push('OK'); break; }
const cls = typeof fail === 'function' ? fail(a, i, history) : (Array.isArray(fail) ? fail[Math.min(i, fail.length - 1)] : fail);
if (i === rotateAt) valid = false;
history.push({ ...a, cls });
}
return seq.join(' > ');
}
function expect(name, input, expected) {
const got = simulate(input);
check(`${name.padEnd(62)} ${got}`, got === expected, { expected });
}
console.log('# Mode never');
expect('never, warp+direct, UNKNOWN', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNKNOWN' }, 'W/A > D/A');
expect('never, warp+direct, BOT_CHECK (jamais de cookies)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('never, warp+direct, HTTP_403 (clients de repli une fois)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'HTTP_403' }, 'W/A > W/A/fb > D/A');
expect('never, direct, LOGIN_REQUIRED (arrêt)', { routes: ROUTES.direct, mode: 'never', fail: 'LOGIN_REQUIRED' }, 'D/A');
expect('never, warp seul, TIMEOUT', { routes: ROUTES['warp seul'], mode: 'never', fail: 'TIMEOUT' }, 'W/A');
expect('never, warp+direct, PRIVATE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'PRIVATE' }, 'W/A');
expect('never, warp+direct, UNAVAILABLE (fatal)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'UNAVAILABLE' }, 'W/A');
expect('never, warp+direct, RATE_LIMITED (autre route uniquement)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'RATE_LIMITED' }, 'W/A > D/A');
expect('never, warp+direct, BINARY_MISSING (arrêt)', { routes: ROUTES['warp+direct'], mode: 'never', fail: 'BINARY_MISSING' }, 'W/A');
console.log('# Mode fallback (cookies valides)');
expect('fallback, warp+direct, BOT_CHECK', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK' }, 'W/A > D/A > D/C');
expect('fallback, warp+direct, IP_BLOCKED, cookiesOnProxy', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'IP_BLOCKED' }, 'W/A > D/A > W/C > D/C');
expect('fallback, direct, CAPTCHA', { routes: ROUTES.direct, fail: 'CAPTCHA' }, 'D/A > D/C');
expect('fallback, warp seul, BOT_CHECK (proxy = seule route)', { routes: ROUTES['warp seul'], fail: 'BOT_CHECK' }, 'W/A > W/C');
expect('fallback, warp+direct, AGE_RESTRICTED (compte directement)', { routes: ROUTES['warp+direct'], fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
expect('fallback, warp+direct, MEMBERS_ONLY', { routes: ROUTES['warp+direct'], fail: 'MEMBERS_ONLY' }, 'W/A > D/C');
expect('fallback, warp+direct, LOGIN_REQUIRED, cookiesOnProxy (refus avec compte = arrêt)', { routes: ROUTES['warp+direct'], cookiesOnProxy: true, fail: 'LOGIN_REQUIRED' }, 'W/A > W/C');
expect('fallback, warp+direct, UNKNOWN (pas de cookies)', { routes: ROUTES['warp+direct'], fail: 'UNKNOWN' }, 'W/A > D/A');
expect('fallback, warp+direct, GEO_BLOCKED (autre route seulement)', { routes: ROUTES['warp+direct'], fail: 'GEO_BLOCKED' }, 'W/A > D/A');
expect('fallback, direct, GEO_BLOCKED', { routes: ROUTES.direct, fail: 'GEO_BLOCKED' }, 'D/A');
expect('fallback, direct, HTTP_403', { routes: ROUTES.direct, fail: 'HTTP_403' }, 'D/A > D/A/fb');
expect('fallback, direct, SABR', { routes: ROUTES.direct, fail: 'SABR' }, 'D/A > D/A/fb');
expect('fallback, direct, POT_MISSING', { routes: ROUTES.direct, fail: 'POT_MISSING' }, 'D/A > D/A/fb');
expect('fallback, direct, EMPTY_DOWNLOAD', { routes: ROUTES.direct, fail: 'EMPTY_DOWNLOAD' }, 'D/A > D/A/fb');
expect('fallback, direct, FORMAT_UNAVAILABLE, repli désactivé', { routes: ROUTES.direct, fail: 'FORMAT_UNAVAILABLE', fallbackClients: '' }, 'D/A');
expect('fallback, warp+direct, 403 puis BOT_CHECK', { routes: ROUTES['warp+direct'], fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A > W/A/fb > D/A > D/C');
expect('fallback, warp+direct, BOT puis 403 (max 4)', { routes: ROUTES['warp+direct'], fail: ['BOT_CHECK', 'BOT_CHECK', 'HTTP_403'] }, 'W/A > D/A > D/C > D/C/fb');
expect('fallback, warp+direct, réussite à la 2e', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', okAt: 1 }, 'W/A > D/A > OK');
expect('fallback, cookies tournés pendant la tentative avec compte', { routes: ROUTES['warp+direct'], fail: 'BOT_CHECK', rotateAt: 2 }, 'W/A > D/A > D/C');
expect('fallback, cookies invalides, BOT_CHECK', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('fallback, cookies invalides, AGE_RESTRICTED (arrêt)', { routes: ROUTES['warp+direct'], cookiesValid: false, fail: 'AGE_RESTRICTED' }, 'W/A');
expect('fallback, compte refusé (AGE avec cookies) -> arrêt', { routes: ROUTES.direct, fail: 'AGE_RESTRICTED' }, 'D/A > D/C');
console.log('# Mode always');
expect('always, warp+direct (pas de compte via WARP)', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'UNKNOWN' }, 'W/A > D/C');
expect('always, warp+direct, cookiesOnProxy', { routes: ROUTES['warp+direct'], mode: 'always', cookiesOnProxy: true, fail: 'UNKNOWN' }, 'W/C > D/C');
expect('always, direct, BOT_CHECK', { routes: ROUTES.direct, mode: 'always', fail: 'BOT_CHECK' }, 'D/C');
expect('always, warp seul (compte autorisé)', { routes: ROUTES['warp seul'], mode: 'always', fail: 'UNKNOWN' }, 'W/C');
expect('always, direct, rotation -> anonyme', { routes: ROUTES.direct, mode: 'always', fail: 'COOKIES_ROTATED', rotateAt: 0 }, 'D/C > D/A');
expect('always, warp+direct, AGE sur WARP anonyme -> compte', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'AGE_RESTRICTED' }, 'W/A > D/C');
expect('always, direct, HTTP_403 -> clients de repli avec compte', { routes: ROUTES.direct, mode: 'always', fail: 'HTTP_403' }, 'D/C > D/C/fb');
expect('always sans cookies valides = never', { routes: ROUTES['warp+direct'], mode: 'always', cookiesValid: false, fail: 'BOT_CHECK' }, 'W/A > D/A');
expect('always, PRIVATE', { routes: ROUTES['warp+direct'], mode: 'always', fail: 'PRIVATE' }, 'W/A');
console.log('# Seek');
check('seekModeFor auto 30 -> native', plan.seekModeFor('auto', 30) === 'native');
check('seekModeFor auto 1200 -> native', plan.seekModeFor('auto', 1200) === 'native');
check('seekModeFor auto 1201 -> sections', plan.seekModeFor('auto', 1201) === 'sections');
check('seekModeFor ffmpeg 10 -> sections', plan.seekModeFor('ffmpeg', 10) === 'sections');
check('seekModeFor native 5000 -> native', plan.seekModeFor('native', 5000) === 'native');
check('seekModeFor sans seek -> null', plan.seekModeFor('auto', 0) === null);
expect('sections + HTTP_403 -> natif puis clients de repli', { routes: ROUTES.direct, seekMode: 'sections', fail: 'HTTP_403' }, 'D/A/sections > D/A/native > D/A/fb/native');
expect('sections + 403 sur warp+direct -> la suite en natif', { routes: ROUTES['warp+direct'], seekMode: 'sections', fail: ['HTTP_403', 'BOT_CHECK'] }, 'W/A/sections > W/A/native > D/A/native > D/C/native');
expect('natif + UNKNOWN', { routes: ROUTES['warp+direct'], seekMode: 'native', fail: 'UNKNOWN' }, 'W/A/native > D/A/native');
console.log('# Matrice (modes x routes x classes)');
const classes = ['BOT_CHECK', 'IP_BLOCKED', 'CAPTCHA', 'RATE_LIMITED', 'HTTP_403', 'HTTP_429', 'AGE_RESTRICTED', 'LOGIN_REQUIRED', 'MEMBERS_ONLY', 'PRIVATE', 'UNAVAILABLE', 'GEO_BLOCKED', 'POT_MISSING', 'SABR', 'JS_CHALLENGE', 'FORMAT_UNAVAILABLE', 'PAGE_RELOAD', 'COOKIES_ROTATED', 'EMPTY_DOWNLOAD', 'BINARY_MISSING', 'TIMEOUT', 'UNKNOWN'];
let maxLen = 0, dup = 0;
for (const mode of ['never', 'always', 'fallback']) {
for (const [rname, routes] of Object.entries(ROUTES)) {
const row = [];
for (const cls of classes) {
const seq = simulate({ routes, mode, fail: cls });
const parts = seq.split(' > ');
maxLen = Math.max(maxLen, parts.length);
if (new Set(parts).size !== parts.length) dup++;
row.push(`${cls}: ${seq}`);
}
console.log(` [${mode} | ${rname}]`);
for (const r of row) console.log(` ${r}`);
}
}
check('jamais plus de 4 tentatives', maxLen <= 4, maxLen);
check('jamais deux fois la même tentative', dup === 0, dup);
check('pas de compte en mode never', !['warp+direct', 'direct', 'warp seul'].some(r => classes.some(c => simulate({ routes: ROUTES[r], mode: 'never', fail: c }).includes('/C'))));
done();
+227
View File
@@ -0,0 +1,227 @@
// Test de backend/src/server/Server.js avec un vrai serveur socket.io (port local) et des clients socket.io-client.
// Toutes les dépendances (bot Discord, utilisateurs, players, Admin, playlists, Google...) sont simulées :
// aucun bot Discord n'est connecté, aucun fichier de data/ n'est lu ni écrit.
const assert = require("assert")
const Module = require("module")
const path = require("path")
const ROOT = require("path").resolve(__dirname, "../../..")
const SRC = ROOT + "/backend/src"
const { io: ioClient } = require(ROOT + "/frontend/node_modules/socket.io-client")
const norm = (p) => p.replace(/\\/g, "/").toLowerCase()
const PORT = 45000 + Math.floor(Math.random() * 1000)
const uncaught = []
process.on("uncaughtException", (e) => { uncaught.push(e); console.error("uncaughtException", e) })
process.on("unhandledRejection", (e) => { uncaught.push(e); console.error("unhandledRejection", e) })
// --- Utilisateurs : chaque lecture renvoie un NOUVEL objet (comme loadUsers() après chaque saveUsers()) ---
const raw = {
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
B: { id: "B", tokens: ["tB"], labels: [] },
C: { id: "C", tokens: ["tC"], labels: [] },
}
function makeUser(r) {
if (!r) return null
const labels = [...r.labels]
return {
identity: { id: r.id, username: "user" + r.id, global_name: "User " + r.id, avatar: null },
labels, auth: { ok: true }, tokens: [...r.tokens],
isAdmin: () => labels.includes("ADMIN"),
isFullBanned: () => labels.includes("BAN"),
isBanned: (g) => labels.includes("BAN_" + g),
isMod: (g) => labels.includes("ADMIN") || labels.includes("MOD_" + g),
isDeleted: () => labels.includes("DELETED"),
needUpdate: () => false, justUpdated() {}, clearToken() {},
removeToken(t) { r.tokens = r.tokens.filter(x => x !== t) },
}
}
const usersStub = {
getUserById: (id) => makeUser(raw[id]),
getUserByToken: (t) => makeUser(Object.values(raw).find(r => r.tokens.includes(t))),
setGuildBan: async (id, g) => { const r = raw[id]; const l = "BAN_" + g; r.labels = r.labels.includes(l) ? r.labels.filter(x => x !== l) : [...r.labels, l]; return true },
setGuildMod: async () => true,
deleteAccount: async (id) => { raw[id].labels = ["DELETED"]; raw[id].tokens = [] },
updateIdentity: async () => true,
addUser: async () => null,
}
// --- Bot / players ---
const guilds = new Map([
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C"] }],
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
])
const AllPlayers = new Map()
const seekCalls = []
class FakePlayer {
constructor(guildId) { if (AllPlayers.has(guildId)) return AllPlayers.get(guildId); this.guildId = guildId; this.connected = false; this.queue = { current: { title: "x" } }; AllPlayers.set(guildId, this) }
isConnected() { return this.connected }
getState() { return { v: 2, guildId: this.guildId, status: this.connected ? "playing" : "idle" } }
async setDuration(t) { seekCalls.push(t); return { ok: true } }
async skip() { return "no_music" }
}
let ioRef = null
const adminStub = {
ADMIN_ROOM: "ADMIN", LOGS_ROOM: "ADMIN_LOGS",
init(io) { ioRef = io }, attach() {},
syncUserSockets(userId) {
const user = usersStub.getUserById(userId)
for (const s of ioRef.sockets.sockets.values()) {
if (s.data.userId !== userId) continue
for (const room of [...s.rooms]) if (guilds.has(room) && user.isBanned(room)) s.leave(room)
}
process.emit("USERS_UPDATE")
},
}
const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } }
let reportResult = false
let finderThrows = true
const stubs = {
"loguix": fakeLog,
[norm(SRC + "/utils/SafeMetric")]: { getGuildMetrics: () => [], toSafeNumber: (v) => v },
[norm(SRC + "/server/auth/Session")]: { checkSession: () => false, addSession: () => "sess", removeSession() {} },
[norm(SRC + "/server/auth/User")]: usersStub,
[norm(SRC + "/player/Player")]: { Player: FakePlayer, AllPlayers, offlineState: (g) => ({ v: 2, guildId: g, status: "offline" }), getPlayer: () => { throw new Error("getPlayer ne doit plus être appelé") }, getAllPlayers: () => [...AllPlayers.values()] },
[norm(SRC + "/discord/Bot")]: { isReady: async () => true, getGuilds: () => guilds, getGuildMember: () => null, getMembersVoices: () => new Map(), getChannel: () => null, getGuildRoles: () => [] },
[norm(SRC + "/server/auth/DiscordAuth")]: {},
[norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } },
[norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } },
[norm(SRC + "/playlists/PlaylistManager")]: { syncYoutube: async () => ({ ok: true }) },
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
[norm(SRC + "/lyrics/Lyrics")]: {},
[norm(SRC + "/discord/ServerSettings")]: { getSecureRole: () => null },
[norm(SRC + "/discord/MediaBase")]: {},
[norm(SRC + "/playlists/Google/OAuth2")]: { getStatus: (id) => ({ configured: false, linked: false, user: id }), isSyncing: () => false },
[norm(SRC + "/utils/Database/Configuration")]: { getPort: () => PORT },
[norm(SRC + "/player/List")]: { List: class { getPrevious() { return [] } } },
[norm(SRC + "/server/Admin")]: adminStub,
[norm(SRC + "/utils/AudioBufferCheck")]: { isAudioFile: () => false },
[norm(SRC + "/player/Song")]: { Song: class {} },
[norm(SRC + "/media/MediaInformation")]: {},
}
const origLoad = Module._load
Module._load = function (request, parent) {
if (stubs[request]) return stubs[request]
if (request.startsWith(".") && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request)).replace(/\.js$/, "")
if (stubs[resolved]) return stubs[resolved]
}
return origLoad.apply(this, arguments)
}
const Server = require(SRC + "/server/Server.js")
assert.strictEqual(typeof Server.init, "function", "Server.init exporté")
Server.init()
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const clients = []
function connect(token) {
return new Promise((resolve, reject) => {
const s = ioClient("http://127.0.0.1:" + PORT, { auth: { token }, reconnection: false, transports: ["websocket"] })
clients.push(s)
s.once("/USER/READY", () => resolve(s))
s.once("AUTH_ERROR", (e) => reject(new Error("AUTH_ERROR " + e)))
setTimeout(() => reject(new Error("timeout connexion " + token)), 3000)
})
}
function request(s, event, data) {
return new Promise((resolve, reject) => {
s.once(event, resolve)
s.emit(event, data)
setTimeout(() => reject(new Error("pas de réponse " + event)), 3000)
})
}
function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
;(async () => {
await sleep(100)
const a1 = await connect("tA1"), a2 = await connect("tA2"), b = await connect("tB")
// --- Rooms conservées lors d'un changement de guilde ---
serverSocket(a1).join("ADMIN_LOGS")
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g1"), true)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "g2"), true)
const rooms = [...serverSocket(a1).rooms]
assert.ok(rooms.includes("g2") && !rooms.includes("g1"), "guilde précédente quittée : " + rooms)
for (const r of ["user:A", "ADMIN", "ADMIN_LOGS", a1.id]) assert.ok(rooms.includes(r), "room conservée : " + r)
assert.strictEqual(await request(a1, "/GUILD/JOIN", "inconnue"), false)
// --- Présence multi-appareils ---
await request(a1, "/GUILD/JOIN", "g1")
await request(a2, "/GUILD/JOIN", "g1")
await request(b, "/GUILD/JOIN", "g1")
let members = await request(b, "/USERS/LIST")
assert.deepStrictEqual(members.map(m => m.id).sort(), ["A", "B"], "A une seule fois")
a1.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(members.some(m => m.id === "A"), "A toujours présent (2e appareil)")
const info = await request(b, "/GUILD/INFO")
assert.deepStrictEqual(info.members.map(m => m.id), ["A"])
a2.disconnect()
await sleep(150)
members = await request(b, "/USERS/LIST")
assert.ok(!members.some(m => m.id === "A"), "A absent après la dernière déconnexion")
// --- PLAYER_STATE : jamais de création de player, /GUILD/UPDATE seulement au changement de connexion ---
let guildUpdates = 0, playerUpdates = 0
b.on("/GUILD/UPDATE", () => guildUpdates++)
b.on("/PLAYER/UPDATE", () => playerUpdates++)
process.emit("PLAYER_STATE", "g1")
process.emit("PLAYERS_UPDATE")
await sleep(100)
assert.strictEqual(AllPlayers.size, 0, "aucun player créé")
assert.strictEqual(playerUpdates, 1, "état hors-ligne envoyé à la room g1")
const player = new FakePlayer("g1"); player.connected = true
process.emit("PLAYER_STATE", "g1"); await sleep(50)
process.emit("PLAYER_STATE", "g1"); await sleep(50)
assert.strictEqual(guildUpdates, 2, "1re émission + passage à connecté uniquement (" + guildUpdates + ")")
assert.strictEqual((await request(b, "/PLAYER/STATE")).status, "playing")
// --- SEEK : 0 accepté, négatif refusé, refus du player transmis ---
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 0 }), true)
assert.strictEqual(await request(b, "/PLAYER/SEEK", 0), true)
assert.deepStrictEqual(seekCalls, [0, 0])
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: -1 }), false)
assert.strictEqual(await request(b, "/PLAYER/SEEK", {}), false)
player.queue.current = null
assert.strictEqual(await request(b, "/PLAYER/SEEK", { time: 5 }), false, "pas de musique : false (pas de ReferenceError)")
assert.strictEqual(await request(b, "/PLAYER/FORWARD"), "no_music")
// --- Exception dans un handler : réponse false, process intact ---
assert.strictEqual(await request(b, "/SEARCH", "test"), false)
// --- /REPORT : résultat réel de l'envoi ---
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), false)
reportResult = true
assert.strictEqual(await request(b, "/REPORT", { level: "bug", desc: "x" }), true)
// --- /MOD/USERS/BAN (plus de UsersBySocket) + droits relus à chaque requête ---
const c = await connect("tC")
await request(c, "/GUILD/JOIN", "g1")
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "le propriétaire bannit B")
await sleep(50)
assert.ok(!serverSocket(b).rooms.has("g1"), "B a quitté la room g1")
assert.strictEqual(await request(b, "/PLAYER/STATE"), false, "B banni : refus immédiat (utilisateur relu)")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), false)
assert.strictEqual(await request(c, "/MOD/USERS/BAN", "B"), true, "levée du ban")
assert.strictEqual(await request(b, "/GUILD/JOIN", "g1"), true)
// --- Bannissement global en cours de session ---
raw.B.labels.push("BAN")
const authError = new Promise(r => b.once("AUTH_ERROR", r))
b.emit("/USER/INFO")
assert.strictEqual(await authError, "Vous êtes banni du serveur")
await sleep(100)
assert.strictEqual(b.connected, false)
// --- /GOOGLE/STATUS par ack ---
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
assert.strictEqual(status.user, "C")
await sleep(100)
assert.deepStrictEqual(uncaught, [], "aucune exception non gérée")
console.log("server-socket.test.js : OK")
clients.forEach(s => s.disconnect())
ioRef.close()
setTimeout(() => process.exit(0), 100)
})().catch((e) => { console.error(e); process.exit(1) })
+155
View File
@@ -0,0 +1,155 @@
// Test de bout en bout : vrai yt-dlp, vrai YouTube, sans proxy (WARP non démarré => route directe)
// Usage : node stream.e2e.js [--no-update] [--no-cookies]
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { BACKEND, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('e2e');
const youtube = req('src/youtube');
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
const warp = req('src/utils/WarpProxy');
const { cookies, settings, ytdlp, diagnostics } = youtube;
const args = process.argv.slice(2);
const TARGET = 200 * 1024;
const LONG_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
function procCount(image) {
if (process.platform !== 'win32') return null;
try {
const out = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${image}`, '/NH'], { encoding: 'utf8', windowsHide: true });
return out.split(/\r?\n/).filter(l => l.toLowerCase().startsWith(image.toLowerCase())).length;
} catch (e) { return null; }
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
/**
* Lit ~200 Ko puis annule via AbortController
*/
async function readThenAbort(label, seek, { expectRaw = false, url } = {}) {
console.log(`\n# ${label}`);
const controller = new AbortController();
const progress = [];
const t0 = Date.now();
const song = { id: 'e2e', title: 'e2e', url: url || settings.get().testUrl, type: 'youtube' };
const stream = await getStream(song, seek, { signal: controller.signal, guildId: 'e2e-guild', onProgress: (p) => progress.push(p) });
const tStream = Date.now() - t0;
console.log(` getStream -> ${stream ? 'flux' : 'null'} en ${tStream} ms, progression : ${JSON.stringify(progress)}`);
console.log(` activeProcesses pendant la lecture : ${JSON.stringify([...activeProcesses.entries()].map(([g, p]) => ({ g, route: p.route, identity: p.identity, pid: !!p.pid })))}`);
if (!stream) {
console.log(' diagnostics :', JSON.stringify(diagnostics.getRecent(4), null, 0));
return { ok: false };
}
check(`${label} : streamType ${expectRaw ? '"raw"' : 'conteneur'}`, expectRaw ? stream.streamType === 'raw' : stream.streamType === undefined);
const chunks = [];
let total = 0;
await new Promise((resolve) => {
const timer = setTimeout(resolve, 60000);
stream.on('data', (c) => {
if (total >= TARGET) return;
chunks.push(c);
total += c.length;
if (total >= TARGET) { clearTimeout(timer); resolve(); }
});
stream.on('close', () => { clearTimeout(timer); resolve(); });
stream.on('error', () => {});
});
const buf = Buffer.concat(chunks);
console.log(` ${total} octets lus en ${Date.now() - t0} ms`);
check(`${label} : ~200 Ko reçus`, total >= TARGET, total);
if (expectRaw) {
// PCM s16le stéréo : vérifie qu'il y a du signal (pas que des zéros)
let sumSq = 0, n = 0, nonZero = 0;
for (let i = 0; i + 1 < buf.length; i += 2) {
const v = buf.readInt16LE(i);
sumSq += v * v; n++;
if (v !== 0) nonZero++;
}
const rms = Math.sqrt(sumSq / Math.max(1, n));
console.log(` PCM : ${n} échantillons, RMS ${rms.toFixed(1)}, ${(100 * nonZero / Math.max(1, n)).toFixed(1)} % non nuls, ~${(n / 2 / 48000).toFixed(2)} s d'audio`);
check(`${label} : PCM non silencieux`, rms > 10 && nonZero > n / 10);
} else {
// WebM (EBML 1A45DFA3) ou MP4 (ftyp)
const magic = buf.subarray(0, 4).toString('hex');
const isWebm = magic === '1a45dfa3';
const isMp4 = buf.subarray(4, 8).toString() === 'ftyp';
console.log(` conteneur : ${isWebm ? 'WebM' : isMp4 ? 'MP4' : 'inconnu (' + magic + ')'}`);
check(`${label} : conteneur audio reconnu`, isWebm || isMp4);
}
controller.abort();
// Processus tués et copies de cookies supprimées
let waited = 0;
while ((ytdlp.activeCount() > 0 || activeProcesses.size > 0 || cookies.liveCopyCount() > 0) && waited < 5000) {
await sleep(100); waited += 100;
}
console.log(` après abort (${waited} ms) : processus suivis ${ytdlp.activeCount()}, activeProcesses ${activeProcesses.size}, copies de cookies ${cookies.liveCopyCount()}, stream détruit ${stream.destroyed}`);
check(`${label} : tous les processus terminés`, ytdlp.activeCount() === 0 && activeProcesses.size === 0);
check(`${label} : flux détruit`, stream.destroyed === true);
check(`${label} : aucune copie chopin-ytc-* restante`, chopinTmpDirs().length === 0 && cookies.liveCopyCount() === 0, chopinTmpDirs());
return { ok: true };
}
(async () => {
console.log('routes WARP :', JSON.stringify(warp.getRoutes().map(r => r ? 'warp' : 'direct')));
const bin = ytdlp.resolveBinary();
const ff = ytdlp.resolveFfmpeg();
console.log(`yt-dlp : ${bin.source} ${path.relative(BACKEND, bin.path) || bin.path}`);
console.log(`ffmpeg : ${ff?.source} ${ff ? path.relative(BACKEND, ff.path) : '-'}`);
console.log(`version : ${await ytdlp.getVersion({ force: true })}`);
const before = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
console.log('processus système avant :', JSON.stringify(before));
if (!args.includes('--no-update')) {
console.log('\n# Mise à jour (yt-dlp -U)');
const up = await ytdlp.update({ reason: 'test e2e' });
console.log(' ', JSON.stringify({ ok: up.ok, updatable: up.updatable, before: up.before, after: up.after, message: up.message }));
console.log(' sortie :', up.output.split('\n').slice(-4).join(' | '));
check('update : version connue après', Boolean(up.after));
}
await readThenAbort('Lecture sans seek (anonyme, directe)', 0);
// testUrl (Me at the zoo) ne dure que 19 s : seek 30 s sur une vidéo plus longue
await readThenAbort('Seek 30 s (natif : yt-dlp -> ffmpeg -> PCM brut)', 30, { expectRaw: true, url: LONG_URL });
console.log('\n# Seek au-delà de la fin (testUrl, 19 s) : un seul essai, sans clients de repli');
const n0 = diagnostics.getRecent(100).length;
const past = await getStream({ id: 'e2e', title: 'e2e', url: settings.get().testUrl, type: 'youtube' }, 30, { guildId: 'e2e-guild' });
const tries = diagnostics.getRecent(100).length - n0;
check('seek après la fin : null en 1 tentative (final)', past === null && tries === 1 && diagnostics.getRecent(1)[0].cls === 'EMPTY_DOWNLOAD', { tries, last: diagnostics.getRecent(1)[0] });
await sleep(2500);
check('seek après la fin : processus terminés', ytdlp.activeCount() === 0, ytdlp.activeCount());
if (!args.includes('--no-cookies')) {
// Anciens cookies (fichiers réels en lecture seule) importés dans le dossier isolé, mode "always"
__glob.COOKIES = path.join(BACKEND, 'data', 'cookies.txt');
__glob.COOKIES_JSON = path.join(BACKEND, 'data', 'cookies.json');
const mig = cookies.migrateLegacy();
console.log('\n# Cookies : migration', JSON.stringify(mig), '-> valides :', cookies.isValid());
if (cookies.isValid()) {
settings.update({ cookiesMode: 'always' }, { username: 'e2e' });
await readThenAbort('Lecture avec compte (mode always, copie temporaire des cookies)', 0);
const st = cookies.getStatus();
console.log(' statut cookies après lecture :', JSON.stringify({ valid: st.valid, invalidatedAt: !!st.invalidatedAt, invalidReason: st.invalidReason }));
}
}
console.log('\n# Diagnostics');
for (const e of diagnostics.getRecent(20).reverse()) {
console.log(` ${e.outcome.padEnd(7)} ${e.route}/${e.identity}${e.clients ? '/' + e.clients : ''}${e.seek ? '/' + e.seek : ''} ${e.ms} ms ${e.cls || ''} ${e.message || ''}`);
}
const s = diagnostics.getSummary();
console.log(' résumé :', JSON.stringify({ ok: s.ok, failed: s.failed, aborted: s.aborted, byClass: s.byClass, consecutiveBlocks: s.consecutiveBlocks }));
await sleep(1000);
console.log(' getStatus().activeProcesses :', youtube.getStatus().activeProcesses);
const after = { ytdlp: procCount('yt-dlp.exe'), ffmpeg: procCount('ffmpeg.exe') };
console.log('processus système après :', JSON.stringify(after));
check('aucun yt-dlp.exe / ffmpeg.exe orphelin', after.ytdlp === null || (after.ytdlp <= before.ytdlp && after.ffmpeg <= before.ffmpeg), { before, after });
check('aucune copie chopin-ytc-* (fin)', chopinTmpDirs().length === 0, chopinTmpDirs());
fs.rmSync(dataDir, { recursive: true, force: true });
done();
// Timers unref : le process se termine seul
})().catch((e) => { console.error(e); process.exitCode = 1; });
+55
View File
@@ -0,0 +1,55 @@
// Cas limites de getStream : binaire absent, annulation avant les données (avec copie de cookies), URL refusée
const fs = require('fs');
const { SCRATCH, __glob, isolateData, check, done, req, chopinTmpDirs } = require('./_setup');
const dataDir = isolateData('edge');
const youtube = req('src/youtube');
const { getStream, activeProcesses } = req('src/player/Method/Youtube.js');
const { cookies, settings, ytdlp, diagnostics } = youtube;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const song = { id: 'edge', title: 'edge', url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', type: 'youtube' };
(async () => {
console.log('# URL refusée');
check('URL "--exec=..." refusée', (await getStream({ url: '--exec=calc' })) === null);
console.log('# Binaire absent');
const savedPath = process.env.PATH, savedRoot = __glob.ROOT;
process.env.PATH = '';
__glob.ROOT = SCRATCH + '/';
const bin = ytdlp.resolveBinary({ force: true });
check('resolveBinary -> missing', bin.source === 'missing', bin);
const n0 = diagnostics.getRecent(100).length;
const r1 = await getStream(song, 0, { guildId: 'g' });
const last = diagnostics.getRecent(1)[0];
check('binaire absent : null, 1 tentative, BINARY_MISSING', r1 === null && diagnostics.getRecent(100).length - n0 === 1 && last.cls === 'BINARY_MISSING', last);
process.env.PATH = savedPath;
__glob.ROOT = savedRoot;
check('binaire retrouvé ensuite', ytdlp.resolveBinary({ force: true }).source !== 'missing');
console.log('# Annulation avant les données (avec compte : copie de cookies)');
const T = (...f) => f.join('\t');
cookies.importCookies([T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'LOGIN_INFO', 'fake'), T('.youtube.com', 'TRUE', '/', 'TRUE', 1830000000, 'SAPISID', 'fake')].join('\n'));
settings.update({ cookiesMode: 'always' });
const controller = new AbortController();
const p = getStream(song, 0, { signal: controller.signal, guildId: 'g2' });
await sleep(400);
const copiesDuring = cookies.liveCopyCount();
const procDuring = ytdlp.activeCount();
controller.abort();
const r2 = await p;
console.log(` pendant : copies ${copiesDuring}, processus ${procDuring}`);
check('annulation : null', r2 === null);
check('annulation : diagnostics "aborted"', diagnostics.getRecent(1)[0].outcome === 'aborted');
let waited = 0;
while ((ytdlp.activeCount() || cookies.liveCopyCount() || chopinTmpDirs().length) && waited < 5000) { await sleep(100); waited += 100; }
console.log(` après ${waited} ms : processus ${ytdlp.activeCount()}, copies ${cookies.liveCopyCount()}, dossiers ${chopinTmpDirs().length}, activeProcesses ${activeProcesses.size}`);
check('annulation : la copie existait pendant la tentative', copiesDuring === 1 && procDuring >= 1);
check('annulation : processus tués, copie supprimée', ytdlp.activeCount() === 0 && cookies.liveCopyCount() === 0 && chopinTmpDirs().length === 0 && activeProcesses.size === 0);
console.log('# Signal déjà annulé');
const c2 = new AbortController(); c2.abort();
check('signal déjà annulé : null sans processus', (await getStream(song, 0, { signal: c2.signal })) === null && ytdlp.activeCount() === 0);
fs.rmSync(dataDir, { recursive: true, force: true });
done();
})().catch((e) => { console.error(e); process.exitCode = 1; });
+20
View File
@@ -0,0 +1,20 @@
// Détecte les identifiants référencés sans liaison (ReferenceError potentielle) via @babel/traverse
const FE = require("path").resolve(__dirname, "../../../frontend/node_modules") + "/"
const parser = require(FE + "@babel/parser")
const traverse = require(FE + "@babel/traverse").default
const fs = require("fs")
const globals = new Set(Object.getOwnPropertyNames(globalThis).concat(["require","module","exports","__dirname","__filename","process","Buffer","console","setTimeout","clearTimeout","setInterval","clearInterval","setImmediate","URL","fetch","AbortController","structuredClone","undefined","arguments"]))
let bad = 0
for (const file of process.argv.slice(2)) {
const ast = parser.parse(fs.readFileSync(file, "utf8"), { sourceType: "script", allowReturnOutsideFunction: true })
traverse(ast, {
ReferencedIdentifier(p) {
const name = p.node.name
if (p.scope.hasBinding(name, true) || globals.has(name)) return
if (p.parentPath.isMemberExpression({ property: p.node }) && !p.parentPath.node.computed) return
bad++
console.log(file.split("/").pop() + ":" + p.node.loc.start.line + " " + name)
}
})
}
console.log(bad ? bad + " identifiant(s) non lié(s)" : "aucun identifiant non lié")
+5
View File
@@ -0,0 +1,5 @@
const { isolateData, req } = require('./_setup');
isolateData('upd');
const ytdlp = req('src/youtube/ytdlp');
const a = ytdlp.update({ reason: 'test' }), b = ytdlp.update();
a.then(r => console.log('single-flight:', a === b, JSON.stringify({ ok: r.ok, updatable: r.updatable, before: r.before, after: r.after, message: r.message })));