Files
NewTube/server/tests/youtube-scrape.test.mjs
T
bruno 4bdcd393fe feat(youtube): InnerTube-first search, transcripts and watch-next related (Steps 15-18)
- 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
2026-09-25 19:38:46 -04:00

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');