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:
2026-09-25 19:38:46 -04:00
parent 300e5c45b1
commit 4bdcd393fe
25 changed files with 4112 additions and 168 deletions
+34 -1
View File
@@ -63,6 +63,16 @@ async function resolveYouTubeChannelId(externalId) {
const raw = String(externalId || '').trim();
if (/^UC[\w-]{20,}$/.test(raw)) return raw;
const handle = raw.replace(/^@/, '');
// 0) scrape sans clé (Step 17) : ne consomme aucun quota
try {
const { getSearchMode } = await import('./youtube-common.mjs');
const mode = getSearchMode();
if (mode !== 'api-only') {
const { resolveChannelIdViaScrape } = await import('./youtube-scrape.mjs');
const id = await resolveChannelIdViaScrape(raw);
if (id && /^UC[\w-]{20,}$/.test(id)) return id;
}
} catch {}
// 1) channels?forHandle (fonctionne encore pour beaucoup de chaînes)
try {
const data = await ytGet('channels', { part: 'id', forHandle: handle });
@@ -131,9 +141,32 @@ function ytTokenFor(key, page) {
}
async function ytContent(externalId, { type, page, limit, sort, q }) {
const channelId = await resolveYouTubeChannelId(externalId);
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
const pageNum = Math.max(1, Number(page || 1));
// Step 17 : scrape-first sans clé (0 quota). Fallback API si bot-check/timeout.
try {
const { getSearchMode } = await import('./youtube-common.mjs');
const mode = getSearchMode();
if (mode !== 'api-only') {
const { fetchChannelViaScrape } = await import('./youtube-scrape.mjs');
// externalId brut (handle ou UC...) : le scrape gère les deux formes
const scraped = await fetchChannelViaScrape(externalId, { type, page: pageNum, limit: perPage });
if (Array.isArray(scraped?.items) && scraped.items.length) {
if (mode === 'scrape-only') return { ...scraped, total: null };
// scrape-first : retour direct si non vide
return { ...scraped, total: null };
}
// vide -> on tente l'API (chaîne à faible volume ou tab non supporté en scrape)
if (mode === 'scrape-only') return { items: [], nextPage: null };
}
} catch (e) {
console.warn('[channel-content/yt] scrape failed, fallback api:', e?.code || e?.message || e);
try {
const { getSearchMode } = await import('./youtube-common.mjs');
if (getSearchMode() === 'scrape-only') return { items: [], nextPage: null };
} catch {}
}
const channelId = await resolveYouTubeChannelId(externalId);
if (type === 'playlists') {
const key = ['pl', channelId, perPage].join('|');
const token = ytTokenFor(key, pageNum);