- InnerTube layer via pinned youtubei.js 18.1.0 (no quota, no key): search with merged continuations (unlimited pages), watch-next related with LockupView mapping, caption-track discovery - 3-layer dispatcher (YT_SEARCH_MODE, default innertube-first): innertube -> yt-dlp scrape -> official API, graceful errors.yt - Robust yt-dlp binary resolution (YT_DLP_PATH > PATH > bundled) with systematic API fallback (fixes spawn ENOENT in UI) - Transcript: InnerTube caption discovery (YT_TRANSCRIPT_SOURCE), reusing pickTrack/orderedTracks/parseTrackText; yt-dlp fallback kept - Watch: sidebar uses real watch-next related[] (/api/details), title-search fallback for other providers - Cache: memory LRU + SQLite (youtube_search_cache, youtube_metrics), never persist empty pages; /healthz observability; /api/trending - Includes pending Step 15/16 leftovers in same files (suggest, test scripts); unrelated provider adapters left uncommitted
99 lines
4.0 KiB
JavaScript
99 lines
4.0 KiB
JavaScript
// Step 17 — tests offline : parsers scrape + dispatcher (AUCUN réseau, AUCUNE clé).
|
|
import assert from 'node:assert/strict';
|
|
import { mapFlatEntry, parseFlatPlaylistJson, classifyScrapeError } from '../providers/youtube-scrape.mjs';
|
|
import { getSearchMode, hashSearchKey, buildYtDlpExtraArgs, resolveYtDlpBin, resetYtDlpBinCache } from '../providers/youtube-common.mjs';
|
|
|
|
function ok(msg) { console.log(`✓ ${msg}`); }
|
|
|
|
// 1) mapFlatEntry
|
|
{
|
|
const s = mapFlatEntry({
|
|
id: 'dQw4w9WgXcQ', title: 'Demo', channel_id: 'UC123', channel: 'Chaine',
|
|
duration: 212, view_count: 1000, timestamp: 1700000000,
|
|
thumbnails: [{ url: 'http://t/low.jpg', height: 90 }, { url: 'http://t/high.jpg', height: 720 }],
|
|
});
|
|
assert.equal(s.id, 'dQw4w9WgXcQ');
|
|
assert.equal(s.url, 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
|
|
assert.equal(s.thumbnail, 'http://t/high.jpg');
|
|
assert.equal(s.duration, 212);
|
|
ok('mapFlatEntry maps flat entry to Suggestion');
|
|
}
|
|
assert.equal(mapFlatEntry(null), null);
|
|
assert.equal(mapFlatEntry({}), null);
|
|
ok('mapFlatEntry null-safe');
|
|
|
|
// 2) parseFlatPlaylistJson : objet + NDJSON + vide
|
|
{
|
|
const raw = JSON.stringify({ entries: [{ id: 'a', title: 'A' }, { id: '', title: 'x' }, null] });
|
|
assert.equal(parseFlatPlaylistJson(raw).length, 1);
|
|
ok('parseFlatPlaylistJson objet .entries');
|
|
}
|
|
{
|
|
const raw = `{"id":"a","title":"A"}\n{"id":"b","title":"B"}\nnot-json\n`;
|
|
assert.equal(parseFlatPlaylistJson(raw).length, 2);
|
|
ok('parseFlatPlaylistJson NDJSON tolerant');
|
|
}
|
|
assert.deepEqual(parseFlatPlaylistJson(''), []);
|
|
assert.deepEqual(parseFlatPlaylistJson('{{{'), []);
|
|
ok('parseFlatPlaylistJson vide/invalide -> []');
|
|
|
|
// 3) getSearchMode défaut innertube-first + normalisation
|
|
{
|
|
const prev = process.env.YT_SEARCH_MODE;
|
|
delete process.env.YT_SEARCH_MODE;
|
|
assert.equal(getSearchMode(), 'innertube-first');
|
|
process.env.YT_SEARCH_MODE = 'API-FIRST';
|
|
assert.equal(getSearchMode(), 'api-first');
|
|
process.env.YT_SEARCH_MODE = 'scrape-first';
|
|
assert.equal(getSearchMode(), 'scrape-first');
|
|
process.env.YT_SEARCH_MODE = 'nonsense';
|
|
assert.equal(getSearchMode(), 'innertube-first');
|
|
if (prev === undefined) delete process.env.YT_SEARCH_MODE; else process.env.YT_SEARCH_MODE = prev;
|
|
ok('getSearchMode défaut + normalisation');
|
|
}
|
|
|
|
// 4) hash stable + extra args jamais de secret en clair autre que proxy path
|
|
{
|
|
assert.equal(hashSearchKey('a'), hashSearchKey('a'));
|
|
assert.notEqual(hashSearchKey('a'), hashSearchKey('b'));
|
|
const args = buildYtDlpExtraArgs();
|
|
assert.ok(Array.isArray(args));
|
|
ok('hashSearchKey + buildYtDlpExtraArgs');
|
|
}
|
|
|
|
// 5) dispatcher scrape-only sans clé : ne doit PAS exiger YOUTUBE_API_KEY au import,
|
|
// et searchViaScrape vide sur query courte
|
|
{
|
|
const { searchViaScrape } = await import('../providers/youtube-scrape.mjs');
|
|
assert.deepEqual(await searchViaScrape('a'), []);
|
|
ok('searchViaScrape query courte -> [] sans réseau');
|
|
}
|
|
|
|
// 6) classifyScrapeError : ENOENT -> yt_scrape_no_binary (message actionnable, jamais de spawn brut en UI)
|
|
{
|
|
const e = classifyScrapeError(Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT', errno: 'ENOENT' }));
|
|
assert.equal(e.code, 'yt_scrape_no_binary');
|
|
assert.equal(e.ytStatus, 503);
|
|
assert.match(e.message, /YT_DLP_PATH/);
|
|
const passthrough = classifyScrapeError(Object.assign(new Error('x'), { code: 'yt_scrape_no_binary' }));
|
|
assert.equal(passthrough.code, 'yt_scrape_no_binary');
|
|
ok('classifyScrapeError ENOENT -> yt_scrape_no_binary');
|
|
}
|
|
|
|
// 7) resolveYtDlpBin : YT_DLP_PATH inexistant -> fallback PATH/bundled (jamais de throw si un binaire existe)
|
|
{
|
|
resetYtDlpBinCache();
|
|
const prev = process.env.YT_DLP_PATH;
|
|
process.env.YT_DLP_PATH = '/nonexistent/yt-dlp';
|
|
try {
|
|
const bin = await resolveYtDlpBin();
|
|
assert.ok(typeof bin === 'string' && bin.length > 0);
|
|
ok(`resolveYtDlpBin fallback -> ${bin}`);
|
|
} finally {
|
|
resetYtDlpBinCache();
|
|
if (prev === undefined) delete process.env.YT_DLP_PATH; else process.env.YT_DLP_PATH = prev;
|
|
}
|
|
}
|
|
|
|
console.log('youtube-scrape tests passed');
|