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
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// Step 15 — Suggest tests: unit parsing/dedup (offline) + API contract (isolated server).
|
||||
// Run with: npm run test:suggest
|
||||
// Scenario coverage (todo Step 15):
|
||||
// - providers=yt returns only yt suggestions
|
||||
// - unknown provider falls back to the full registry
|
||||
// Assertions are on fan-out shape, never on external provider content.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
|
||||
function expect(cond, msg) {
|
||||
if (!cond) throw new Error(`Assertion failed: ${msg}`);
|
||||
}
|
||||
function logOk(msg) { console.log(`✓ ${msg}`); }
|
||||
|
||||
// ---------- 1) Offline unit tests: parsers + graceful degradation ----------
|
||||
const yt = (await import('../providers/youtube.mjs')).default;
|
||||
const { parseYoutubeSuggestResponse } = await import('../providers/youtube.mjs');
|
||||
const dm = (await import('../providers/dailymotion.mjs')).default;
|
||||
|
||||
// Bare JSON payload
|
||||
expect(
|
||||
JSON.stringify(parseYoutubeSuggestResponse('["tut",["tutoriel angular","tutoriel android"],[],{}]')) ===
|
||||
JSON.stringify(['tutoriel angular', 'tutoriel android']),
|
||||
'bare JSON suggest payload parses',
|
||||
);
|
||||
logOk('parseYoutubeSuggestResponse bare JSON');
|
||||
|
||||
// window.google.ac.h(...) wrapped payload
|
||||
expect(
|
||||
JSON.stringify(
|
||||
parseYoutubeSuggestResponse('window.google.ac.h(["tut",["tutoriel angular"],[],{}])'),
|
||||
) === JSON.stringify(['tutoriel angular']),
|
||||
'wrapped JSONP suggest payload parses',
|
||||
);
|
||||
logOk('parseYoutubeSuggestResponse wrapped payload');
|
||||
|
||||
// Array-wrapped suggestions (newer format: [text, type, ...])
|
||||
expect(
|
||||
JSON.stringify(parseYoutubeSuggestResponse('["tut",[["tutoriel angular",0],["tutoriel android",0]]]')) ===
|
||||
JSON.stringify(['tutoriel angular', 'tutoriel android']),
|
||||
'array-wrapped suggestions keep text only',
|
||||
);
|
||||
logOk('parseYoutubeSuggestResponse array-wrapped entries');
|
||||
|
||||
// Garbage / empty payloads never throw
|
||||
expect(parseYoutubeSuggestResponse('not json at all').length === 0, 'garbage payload yields []');
|
||||
expect(parseYoutubeSuggestResponse('').length === 0, 'empty payload yields []');
|
||||
logOk('parseYoutubeSuggestResponse graceful degradation');
|
||||
|
||||
// min-length guard: no network below 2 chars (would throw offline otherwise)
|
||||
expect(JSON.stringify(await yt.suggest('a')) === '[]', 'yt.suggest short query returns [] without network');
|
||||
expect(JSON.stringify(await dm.suggest('x')) === '[]', 'dm.suggest short query returns [] without network');
|
||||
logOk('suggest min-length guard (no network)');
|
||||
|
||||
// ---------- 2) API contract against a real isolated server ----------
|
||||
const PORT = await new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const p = srv.address().port;
|
||||
srv.close(() => resolve(p));
|
||||
});
|
||||
});
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-suggest-test-'));
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: path.join(tmpDir, 'suggest.db'), JWT_SECRET: 'suggest-test-secret', NODE_ENV: 'test' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
});
|
||||
let serverLogs = '';
|
||||
server.stdout.on('data', (d) => { serverLogs += d.toString(); });
|
||||
server.stderr.on('data', (d) => { serverLogs += d.toString(); });
|
||||
const baseUrl = `http://127.0.0.1:${PORT}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function waitForServer(timeoutMs = 20000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/`);
|
||||
if (res.status < 500) return true;
|
||||
} catch {}
|
||||
await sleep(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
async function scenario(name, fn) {
|
||||
try { await fn(); logOk(name); }
|
||||
catch (e) { failures += 1; console.error(`✗ ${name}`); console.error(e?.stack || String(e)); }
|
||||
}
|
||||
|
||||
try {
|
||||
const up = await waitForServer();
|
||||
expect(up, 'isolated server starts');
|
||||
|
||||
await scenario('q too short -> 400', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search/suggest?q=a`);
|
||||
expect(res.status === 400, `expected 400, got ${res.status}`);
|
||||
});
|
||||
|
||||
await scenario('providers=yt returns only yt suggestions', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=yt&limit=5`);
|
||||
expect(res.status === 200, `expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
expect(body.q === 'tutoriel', 'q echoed');
|
||||
expect(JSON.stringify(Object.keys(body.groups)) === JSON.stringify(['yt']), `only yt group (got ${Object.keys(body.groups)})`);
|
||||
expect(Array.isArray(body.groups.yt), 'yt group is an array');
|
||||
expect(body.groups.yt.length <= 5, 'limit respected');
|
||||
});
|
||||
|
||||
await scenario('unknown provider falls back to the full registry', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=xx,yy&limit=3`);
|
||||
expect(res.status === 200, `expected 200, got ${res.status}`);
|
||||
const body = await res.json();
|
||||
const keys = Object.keys(body.groups).sort();
|
||||
expect(JSON.stringify(keys) === JSON.stringify(['dm', 'od', 'pt', 'ru', 'tw', 'yt']), `full registry fallback (got ${keys})`);
|
||||
});
|
||||
} finally {
|
||||
server.kill();
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} suggest scenario(s) failed. Server logs:\n${serverLogs}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll suggest tests passed.');
|
||||
@@ -0,0 +1,212 @@
|
||||
// Step 16 — Transcript tests (offline fixtures for parsers + API contract).
|
||||
// Run with: npm run test:transcript
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import {
|
||||
pickTrack,
|
||||
parseJson3,
|
||||
parseVtt,
|
||||
parseTrackText,
|
||||
parseXmlCaptions,
|
||||
orderedTracks,
|
||||
looksLikeHtmlError,
|
||||
capLines,
|
||||
normalizeTranscriptProvider,
|
||||
} from '../transcript.mjs';
|
||||
|
||||
const track = (url, ext = 'vtt') => ({ url, ext });
|
||||
|
||||
describe('pickTrack', () => {
|
||||
it('prefers manual subtitles over automatic captions', () => {
|
||||
const json = {
|
||||
subtitles: { en: [track('https://x/en.vtt')] },
|
||||
automatic_captions: { en: [track('https://x/en.auto.vtt')] },
|
||||
};
|
||||
const { track: t, lang } = pickTrack(json, 'en');
|
||||
assert.equal(t.url, 'https://x/en.vtt');
|
||||
assert.equal(lang, 'en');
|
||||
});
|
||||
|
||||
it('falls back fr -> fr-* -> auto -> first available', () => {
|
||||
const json = {
|
||||
subtitles: { 'fr-CA': [track('https://x/frca.vtt')] },
|
||||
automatic_captions: { en: [track('https://x/en.auto.vtt')] },
|
||||
};
|
||||
assert.equal(pickTrack(json, 'fr').lang, 'fr-CA');
|
||||
// 'de' matches nothing: first available (manual preferred)
|
||||
const fb = pickTrack(json, 'de');
|
||||
assert.equal(fb.lang, 'fr-CA');
|
||||
// auto-only dict
|
||||
const autoOnly = pickTrack({ automatic_captions: { en: [track('https://x/e.vtt')] } }, 'en');
|
||||
assert.equal(autoOnly.track.url, 'https://x/e.vtt');
|
||||
});
|
||||
|
||||
it('returns null track when no subtitles exist', () => {
|
||||
assert.deepEqual(pickTrack({}, 'fr'), { track: null, languages: [], lang: null });
|
||||
assert.deepEqual(pickTrack({ subtitles: {}, automatic_captions: {} }, 'en'), {
|
||||
track: null,
|
||||
languages: [],
|
||||
lang: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes the language list for the UI selector', () => {
|
||||
const { languages } = pickTrack(
|
||||
{ subtitles: { fr: [track('a')], en: [track('b')] }, automatic_captions: { es: [track('c')] } },
|
||||
'fr',
|
||||
);
|
||||
assert.deepEqual([...languages].sort(), ['en', 'es', 'fr']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJson3', () => {
|
||||
it('converts events with segs to normalized lines', () => {
|
||||
const lines = parseJson3({
|
||||
events: [
|
||||
{ tStartMs: 0, dDurationMs: 2500, segs: [{ utf8: 'Bonjour' }] },
|
||||
{ tStartMs: 2500, dDurationMs: 3100, segs: [{ utf8: 'Bien' }, { utf8: 'venue' }] },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(lines, [
|
||||
{ t: 0, dur: 2.5, text: 'Bonjour' },
|
||||
{ t: 2.5, dur: 3.1, text: 'Bienvenue' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops events without segs or with empty text', () => {
|
||||
const lines = parseJson3({ events: [{ tStartMs: 0 }, { tStartMs: 1, dDurationMs: 1, segs: [{ utf8: ' ' }] }] });
|
||||
assert.deepEqual(lines, []);
|
||||
assert.deepEqual(parseJson3({}), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVtt', () => {
|
||||
const vtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:02.500
|
||||
Bonjour <c.colorE5E5E5>à tous</c>
|
||||
|
||||
12
|
||||
00:00:02.500 --> 00:00:05.600
|
||||
Bienvenue
|
||||
dans cette vidéo
|
||||
`;
|
||||
it('parses cues with identifiers, tags and multi-line content', () => {
|
||||
const lines = parseVtt(vtt);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.equal(lines[0].t, 0);
|
||||
assert.equal(lines[0].dur, 2.5);
|
||||
assert.equal(lines[0].text, 'Bonjour à tous');
|
||||
assert.equal(lines[1].text, 'Bienvenue dans cette vidéo');
|
||||
assert.ok(Math.abs(lines[1].t - 2.5) < 1e-9);
|
||||
});
|
||||
|
||||
it('supports MM:SS.mmm timestamps and decodes entities', () => {
|
||||
const lines = parseVtt('WEBVTT\n\n01:02.000 --> 01:04.500\nFish & chips\n');
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0].t, 62);
|
||||
assert.equal(lines[0].text, 'Fish & chips');
|
||||
});
|
||||
|
||||
it('ignores garbage blocks', () => {
|
||||
assert.deepEqual(parseVtt('WEBVTT\n\nNOTE nothing here\n'), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderedTracks + parseTrackText + XML captions', () => {
|
||||
const auto = {
|
||||
fr: [{ url: 'https://x/fr.json3', ext: 'json3' }],
|
||||
en: [{ url: 'https://x/en.json3', ext: 'json3' }],
|
||||
es: [{ url: 'https://x/es.vtt', ext: 'vtt' }],
|
||||
};
|
||||
it('tries requested lang first, then original en', () => {
|
||||
const langs = orderedTracks({ subtitles: {}, automatic_captions: auto }, 'fr').map((o) => o.lang);
|
||||
assert.deepEqual(langs, ['fr', 'en', 'es']);
|
||||
});
|
||||
it('dedupes identical track URLs', () => {
|
||||
const dup = { subtitles: {}, automatic_captions: { en: [{ url: 'https://x/same', ext: 'vtt' }], fr: [{ url: 'https://x/same', ext: 'vtt' }] } };
|
||||
assert.equal(orderedTracks(dup, 'fr').length, 1);
|
||||
});
|
||||
it('detects HTML error pages instead of JSON-parsing them', () => {
|
||||
assert.equal(looksLikeHtmlError('<!DOCTYPE html><html>Sorry</html>'), true);
|
||||
assert.equal(looksLikeHtmlError('WEBVTT\n\n00:00:00.000 --> 1'), false);
|
||||
assert.deepEqual(parseTrackText('<!DOCTYPE html> nope', 'json3'), []);
|
||||
});
|
||||
it('parses srv/ttml XML captions', () => {
|
||||
const srv = parseXmlCaptions('<transcript><p t="0" d="2500">Hello</p></transcript>');
|
||||
assert.equal(srv.length, 1);
|
||||
assert.equal(srv[0].text, 'Hello');
|
||||
const ttml = parseXmlCaptions('<tt><body><div><p begin="00:00:01.000" end="00:00:03.500">Bonjour</p></div></body></tt>');
|
||||
assert.equal(ttml.length, 1);
|
||||
assert.equal(ttml[0].t, 1);
|
||||
});
|
||||
it('parseTrackText handles json3 and vtt payloads', () => {
|
||||
const j = parseTrackText(JSON.stringify({ events: [{ tStartMs: 0, dDurationMs: 1000, segs: [{ utf8: 'Hi' }] }] }), 'json3');
|
||||
assert.equal(j.length, 1);
|
||||
const v = parseTrackText('WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nHello\n', 'vtt');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capLines + normalizeTranscriptProvider', () => { it('truncates very long transcripts', () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => ({ t: i, dur: 1, text: `l${i}` }));
|
||||
assert.equal(capLines(lines, 3).length, 3);
|
||||
});
|
||||
it('maps short and long provider ids', () => {
|
||||
assert.equal(normalizeTranscriptProvider('yt'), 'youtube');
|
||||
assert.equal(normalizeTranscriptProvider('youtube'), 'youtube');
|
||||
assert.equal(normalizeTranscriptProvider('dm'), 'dailymotion');
|
||||
assert.equal(normalizeTranscriptProvider('pt'), 'peertube');
|
||||
assert.equal(normalizeTranscriptProvider('xx'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API contract', () => {
|
||||
it('rejects unknown providers with 400 { available: false }', async () => {
|
||||
const port = await new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const p = srv.address().port;
|
||||
srv.close(() => resolve(p));
|
||||
});
|
||||
});
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-transcript-test-'));
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
NEWTUBE_DB_FILE: path.join(tmpDir, 'transcript.db'),
|
||||
JWT_SECRET: 'transcript-test-secret',
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
});
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
try {
|
||||
let up = false;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 20000) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/`);
|
||||
if (res.status < 500) { up = true; break; }
|
||||
} catch {}
|
||||
await sleep(300);
|
||||
}
|
||||
assert.ok(up, 'isolated server starts');
|
||||
const res = await fetch(`${baseUrl}/api/transcript/xx/abc123?lang=fr`);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.available, false);
|
||||
assert.ok(typeof body.error === 'string');
|
||||
} finally {
|
||||
server.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes).
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapVideoNode, mapLockupView, mapNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
||||
import { pickTrack, orderedTracks } from '../transcript.mjs';
|
||||
import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs';
|
||||
|
||||
function ok(msg) { console.log(`✓ ${msg}`); }
|
||||
|
||||
// 1) parseViewsText FR/EN
|
||||
{
|
||||
assert.equal(parseViewsText('81 973 vues'), 81973);
|
||||
assert.equal(parseViewsText('1,2 M vues'), 1200000);
|
||||
assert.equal(parseViewsText('57K'), 57000);
|
||||
assert.equal(parseViewsText('3 k vues'), 3000);
|
||||
assert.equal(parseViewsText(''), undefined);
|
||||
assert.equal(parseViewsText(null), undefined);
|
||||
ok('parseViewsText FR/EN');
|
||||
}
|
||||
|
||||
// 1b) parseDurationLabel (a11y)
|
||||
{
|
||||
assert.equal(parseDurationLabel("What's new in Angular 18 2 minutes, 27 seconds"), 147);
|
||||
assert.equal(parseDurationLabel('Live 1 heure, 5 minutes'), 3900);
|
||||
assert.equal(parseDurationLabel('nonsense'), undefined);
|
||||
ok('parseDurationLabel');
|
||||
}
|
||||
|
||||
// 2) mapVideoNode type Video (forme réelle youtubei.js v18)
|
||||
{
|
||||
const m = mapVideoNode({
|
||||
type: 'Video', id: 'abc123',
|
||||
title: { text: 'Demo' },
|
||||
duration: { text: '15:22', seconds: 922 },
|
||||
thumbnails: [{ url: 'http://t/low.jpg' }, { url: 'http://t/high.jpg' }],
|
||||
author: { id: 'UC999', name: 'Chaine' },
|
||||
published: { text: 'il y a 2 ans' },
|
||||
view_count: { text: '81 973 vues' },
|
||||
badges: [{ label: 'Sous-titres' }],
|
||||
is_live: false,
|
||||
});
|
||||
assert.equal(m.id, 'abc123');
|
||||
assert.equal(m.url, 'https://www.youtube.com/watch?v=abc123');
|
||||
assert.equal(m.thumbnail, 'http://t/high.jpg');
|
||||
assert.equal(m.duration, 922);
|
||||
assert.equal(m.views, 81973);
|
||||
assert.equal(m.channelId, 'UC999');
|
||||
assert.deepEqual(m.badges, ['Sous-titres']);
|
||||
assert.equal(m.isShort, undefined);
|
||||
ok('mapVideoNode Video');
|
||||
}
|
||||
|
||||
// 3) Shorts/Reel -> isShort ; live -> isLive ; bruit filtré
|
||||
{
|
||||
const s = mapVideoNode({ type: 'ShortsLockupView', id: 'sh1', title: { text: 'Short' }, thumbnails: [{ url: 'u' }] });
|
||||
assert.equal(s.isShort, true);
|
||||
const l = mapVideoNode({ type: 'Video', id: 'lv1', title: { text: 'Live' }, thumbnails: [{ url: 'u' }], is_live: true });
|
||||
assert.equal(l.isLive, true);
|
||||
assert.equal(mapVideoNode({ type: 'Playlist', id: 'PL1', title: { text: 'PL' } }), null);
|
||||
assert.equal(mapVideoNode({ type: 'Channel', id: 'UC1', title: { text: 'C' } }), null);
|
||||
assert.equal(mapVideoNode(null), null);
|
||||
assert.equal(mapVideoNode({ type: 'Video', title: { text: 'no id' } }), null);
|
||||
ok('mapVideoNode shorts/live/filtrage');
|
||||
}
|
||||
|
||||
// 4) mapNodes déduplique
|
||||
{
|
||||
const mk = (id) => ({ type: 'Video', id, title: { text: id }, thumbnails: [{ url: 'u' }] });
|
||||
const out = mapNodes([mk('a'), mk('a'), mk('b'), null]);
|
||||
assert.deepEqual(out.map((x) => x.id), ['a', 'b']);
|
||||
ok('mapNodes dédup');
|
||||
}
|
||||
|
||||
// 4b) mapLockupView (renderer moderne du watch-next façon SmartTube)
|
||||
{
|
||||
const m = mapLockupView({
|
||||
type: 'LockupView', content_id: '0wIL6d5TxGc', content_type: 'VIDEO',
|
||||
content_image: { image: [{ url: 'http://t/small.jpg', width: 168 }, { url: 'http://t/big.jpg', width: 336 }] },
|
||||
metadata: {
|
||||
title: { text: "What's new" },
|
||||
metadata: { metadata_rows: [
|
||||
{ metadata_parts: [{ text: { text: 'Rainer Hahnekamp' } }] },
|
||||
{ metadata_parts: [{ text: { text: '57K' } }] },
|
||||
] },
|
||||
image: { a11y_label: 'Go to channel Rainer Hahnekamp' },
|
||||
},
|
||||
renderer_context: { accessibility_context: { label: "What's new 2 minutes, 27 seconds" } },
|
||||
});
|
||||
assert.equal(m.id, '0wIL6d5TxGc');
|
||||
assert.equal(m.url, 'https://www.youtube.com/watch?v=0wIL6d5TxGc');
|
||||
assert.equal(m.thumbnail, 'http://t/big.jpg');
|
||||
assert.equal(m.uploaderName, 'Rainer Hahnekamp');
|
||||
assert.equal(m.views, 57000);
|
||||
assert.equal(m.duration, 147);
|
||||
assert.equal(mapVideoNode({ type: 'LockupView', content_id: 'x', content_type: 'VIDEO', metadata: { title: { text: 'T' } } }).id, 'x');
|
||||
assert.equal(mapLockupView({ type: 'LockupView', content_type: 'PLAYLIST', content_id: 'PL1', metadata: { title: { text: 'PL' } } }), null);
|
||||
ok('mapLockupView watch-next');
|
||||
}
|
||||
|
||||
// 5) modes innertube connus
|
||||
{
|
||||
assert.ok(YT_SEARCH_MODES.includes('innertube-first'));
|
||||
assert.ok(YT_SEARCH_MODES.includes('innertube-only'));
|
||||
const prev = process.env.YT_SEARCH_MODE;
|
||||
process.env.YT_SEARCH_MODE = 'innertube-only';
|
||||
assert.equal(getSearchMode(), 'innertube-only');
|
||||
if (prev === undefined) delete process.env.YT_SEARCH_MODE; else process.env.YT_SEARCH_MODE = prev;
|
||||
ok('modes innertube-first/only');
|
||||
}
|
||||
|
||||
// 6) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
||||
{
|
||||
const cap = mapCaptionTracks([
|
||||
{ base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } },
|
||||
{ base_url: 'https://x/t?caps=m&x=2', language_code: 'fr', name: { text: 'Français' } },
|
||||
{ base_url: '', language_code: 'de', name: 'Deutsch' },
|
||||
]);
|
||||
assert.deepEqual(cap.languages.sort(), ['en', 'fr']);
|
||||
assert.equal(cap.trackCount, 2);
|
||||
assert.match(cap.automatic_captions.en[0].url, /fmt=json3/);
|
||||
assert.equal(cap.automatic_captions.en[0].ext, 'json3');
|
||||
assert.equal(cap.subtitles.fr[0].name, 'Français');
|
||||
// Interop : le pseudo dump passe dans pickTrack/orderedTracks sans modification
|
||||
const picked = pickTrack({ subtitles: cap.subtitles, automatic_captions: cap.automatic_captions }, 'fr');
|
||||
assert.equal(picked.lang, 'fr');
|
||||
assert.ok(picked.track.url.includes('fmt=json3'));
|
||||
const ordered = orderedTracks({ subtitles: cap.subtitles, automatic_captions: cap.automatic_captions }, 'de');
|
||||
assert.ok(ordered.length >= 2);
|
||||
ok('mapCaptionTracks + interop pickTrack/orderedTracks');
|
||||
}
|
||||
|
||||
console.log('youtube-innertube tests passed');
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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');
|
||||
Reference in New Issue
Block a user