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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user