CI / build-and-test (push) Successful in 14m10s
Barre de recherche - Panneau de filtres unifié (remplace le ProviderPicker) : sources, type (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année), durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F, recherches récentes, « mémoriser par défaut », « Réinitialiser ». - Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur. - Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants, Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon recherche brute), Tab complète sans chercher, Échap ferme puis vide, ligne « Rechercher <q> », défilement de l'option active, aria à jour. - Focus : le panneau se referme dès que le focus quitte la barre (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les lignes pour garder le focus dans l'input (plus de scintillement). - Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des filtres et sont retirés de la requête (autocomplétion après le `:`). Filtres côté serveur - Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs. - /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type, duration, features), Data API v3 (type, videoDuration, publishedAfter), yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif. Un champ manquant ne fait jamais disparaître un résultat. - Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache). - Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues. Tests / docs - 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres), server/tests/search-filters.test.mjs (npm run test:filters) + CI, scénarios e2e des filtres, README/api/README/todo/MCP à jour.
432 lines
18 KiB
JavaScript
432 lines
18 KiB
JavaScript
// Couche InnerTube directe (façon SmartTube/MediaServiceCore) via youtubei.js.
|
|
// WEB client : search + continuations (pagination illimitée), watch-next (related),
|
|
// sans clé API ni quota. Le dispatcher bascule sur scrape/API si indisponible.
|
|
import { hashSearchKey, ytMetrics } from './youtube-common.mjs';
|
|
import { innertubeSearchFilters, filtersCacheKey } from '../search-filters.mjs';
|
|
|
|
let sessionPromise = null;
|
|
let LogSilenced = false;
|
|
|
|
async function silenceLibNoise() {
|
|
if (LogSilenced) return;
|
|
LogSilenced = true;
|
|
try {
|
|
const { Log } = await import('youtubei.js');
|
|
// youtubei.js loggue en WARN chaque Text sans run assorti (bruit sur les titres) :
|
|
// on ne garde que les erreurs.
|
|
if (Log?.set_level && Log?.Level) {
|
|
const lvl = Log.Level.ERROR ?? Log.Level.WARNING ?? 3;
|
|
Log.set_level(lvl);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
/** Session InnerTube singleton (lazy). Throw code yt_innertube_unavailable si KO. */
|
|
export async function getSession() {
|
|
if (!sessionPromise) {
|
|
sessionPromise = (async () => {
|
|
try {
|
|
await silenceLibNoise();
|
|
const { Innertube } = await import('youtubei.js');
|
|
const gl = String(process.env.YT_INNERTUBE_GL || 'FR').trim() || 'FR';
|
|
const hl = String(process.env.YT_INNERTUBE_HL || 'fr').trim() || 'fr';
|
|
return await Innertube.create({ lang: hl, location: gl });
|
|
} catch (e) {
|
|
sessionPromise = null; // retry au prochain appel
|
|
throw Object.assign(
|
|
new Error(`InnerTube indisponible : ${String(e?.message || e).slice(0, 160)}`),
|
|
{ ytStatus: 502, code: 'yt_innertube_unavailable' },
|
|
);
|
|
}
|
|
})();
|
|
}
|
|
return sessionPromise;
|
|
}
|
|
|
|
export function resetSession() { sessionPromise = null; }
|
|
|
|
/** "81 973 vues" / "57K" / "1,2 M vues" -> number | undefined. */
|
|
export function parseViewsText(t) {
|
|
try {
|
|
const s = String(t || '').trim();
|
|
if (!s) return undefined;
|
|
const m = s.match(/([\d\s\u00a0.,]+)\s*([KMBkmb]|Mds|M|k|B)?/);
|
|
if (!m) return undefined;
|
|
const num = Number(m[1].replace(/[\s\u00a0]/g, '').replace(',', '.'));
|
|
if (!Number.isFinite(num)) return undefined;
|
|
const suffix = (m[2] || '').toLowerCase();
|
|
const mult = suffix === 'b' ? 1e9 : suffix === 'm' || suffix === 'mds' ? 1e6 : suffix === 'k' ? 1e3 : 1;
|
|
const v = Math.round(num * mult);
|
|
return v > 0 ? v : undefined;
|
|
} catch { return undefined; }
|
|
}
|
|
|
|
/** "2 minutes, 27 seconds" / "1 heure, 5 minutes" (label a11y) -> secondes. */
|
|
export function parseDurationLabel(label) {
|
|
try {
|
|
const s = String(label || '').toLowerCase();
|
|
if (!s) return undefined;
|
|
const get = (re) => { const m = s.match(re); return m ? Number(m[1]) : 0; };
|
|
const h = get(/(\d+)\s*(?:hours?|heures?)/);
|
|
const mnt = get(/(\d+)\s*(?:minutes?)/);
|
|
const sec = get(/(\d+)\s*(?:seconds?|secondes?)/);
|
|
const total = h * 3600 + mnt * 60 + sec;
|
|
return total > 0 ? total : undefined;
|
|
} catch { return undefined; }
|
|
}
|
|
|
|
function bestThumb(thumbs) {
|
|
try {
|
|
const arr = Array.isArray(thumbs) ? thumbs.filter((t) => t?.url) : [];
|
|
if (!arr.length) return undefined;
|
|
return arr[arr.length - 1].url;
|
|
} catch { return undefined; }
|
|
}
|
|
|
|
/**
|
|
* Mappe un node InnerTube (Video, CompactVideo, GridVideo, LockupView, ReelItem,
|
|
* ShortsLockupView, PlaylistPanelVideo, WatchCardCompactVideo) vers Suggestion.
|
|
* Pur et testable offline. Les LockupView (nouveau renderer YouTube, utilisé
|
|
* notamment dans le watch-next façon SmartTube) ont une forme imbriquée propre.
|
|
*/
|
|
export function mapVideoNode(n) {
|
|
if (!n || typeof n !== 'object') return null;
|
|
try {
|
|
const type = String(n.type || '');
|
|
if (type === 'LockupView') return mapLockupView(n);
|
|
if (/playlist|channel|gridchannel|shelf|radio|show|album/i.test(type)
|
|
&& !/playlistpanelvideo|watchcard/i.test(type)) return null;
|
|
// Les Shorts (ShortsLockupView / ReelItem) n'ont pas de `id` : il vit dans
|
|
// l'endpoint de navigation. Sans cela, une recherche `type=shorts` ne
|
|
// remonterait aucun résultat.
|
|
const entityId = n.entity_id ? String(n.entity_id) : '';
|
|
const id = n.id ? String(n.id) : (
|
|
n.on_tap_endpoint?.payload?.videoId
|
|
|| n.inline_player_data?.payload?.videoId
|
|
|| n.on_tap?.innertubeCommand?.reelWatchEndpoint?.videoId
|
|
|| n.on_tap?.innertubeCommand?.reelPlayerEndpoint?.videoId
|
|
|| n.navigation_endpoint?.watchEndpoint?.videoId
|
|
|| n.innertubeCommand?.reelWatchEndpoint?.videoId
|
|
// `shorts-shelf-item-<videoId>` : dernier recours.
|
|
|| (entityId.startsWith('shorts-shelf-item-') ? entityId.slice('shorts-shelf-item-'.length) : '')
|
|
|| null
|
|
);
|
|
if (!id) return null;
|
|
// `??` ne suffit pas : `typeof n.title === 'string' ? … : ''` renvoie ''
|
|
// (non nullish) et court-circuiterait les replis. On chaîne donc à part.
|
|
const title = n.title?.text
|
|
|| (typeof n.title === 'string' ? n.title : '')
|
|
// ShortsLockupView / ReelItem : le titre est dans les métadonnées superposées.
|
|
|| n.overlay_metadata?.primary_text?.text
|
|
|| n.overlay_metadata?.headline?.text
|
|
|| '';
|
|
if (!title) return null;
|
|
const duration = n.duration?.seconds != null ? Number(n.duration.seconds) : undefined;
|
|
const author = n.author || n.uploader || null;
|
|
const authorName = author?.name ?? (typeof author === 'string' ? author : undefined);
|
|
const authorId = author?.id ? String(author.id) : undefined;
|
|
const views = parseViewsText(
|
|
n.view_count?.text ?? n.view_count ?? n.views?.text
|
|
// ShortsLockupView : vues dans la 2e ligne des métadonnées superposées.
|
|
?? n.overlay_metadata?.secondary_text?.text,
|
|
);
|
|
// Vignette : `thumbnails[]` (classique), `thumbnail` (ShortsLockupView :
|
|
// tableau de Thumbnail) ou la vignette portée par l'endpoint du Short.
|
|
const thumb = bestThumb(n.thumbnails)
|
|
|| bestThumb(n.thumbnail)
|
|
|| bestThumb(n.on_tap_endpoint?.payload?.thumbnail?.thumbnails)
|
|
|| undefined;
|
|
const isShort = /short|reel/i.test(type) || (Number.isFinite(duration) && duration > 0 && duration <= 70 && /short/i.test(title) === false && /reel|short/i.test(type));
|
|
const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : [];
|
|
return {
|
|
title: String(title),
|
|
id,
|
|
url: `https://www.youtube.com/watch?v=${id}`,
|
|
thumbnail: thumb,
|
|
uploaderName: authorName,
|
|
type: 'video',
|
|
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
|
...(views !== undefined ? { views } : {}),
|
|
...(n.published?.text ? { publishedAt: String(n.published.text) } : {}),
|
|
...(authorId ? { channelId: authorId, channelExternalId: authorId, channelUrl: `https://www.youtube.com/channel/${authorId}` } : {}),
|
|
...(authorName ? { channelHandle: String(authorName) } : {}),
|
|
...(n.is_live ? { isLive: true } : {}),
|
|
...(isShort ? { isShort: true } : {}),
|
|
...(badges.length ? { badges } : {}),
|
|
};
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** Mappe un LockupView (renderer moderne : watch-next, search, shelves). */
|
|
export function mapLockupView(n) {
|
|
try {
|
|
const ct = String(n.content_type || '').toUpperCase();
|
|
if (ct && !['VIDEO', 'SHORT', 'MOVIE', 'LIVE'].includes(ct)) return null;
|
|
const id = n.content_id ? String(n.content_id)
|
|
: (n.renderer_context?.command_context?.on_tap?.payload?.videoId || null);
|
|
if (!id) return null;
|
|
const meta = n.metadata || {};
|
|
const title = meta.title?.text ?? (typeof meta.title === 'string' ? meta.title : '');
|
|
if (!title) return null;
|
|
// Vignette : content_image.image[] (prend la plus large)
|
|
let thumbnail;
|
|
try {
|
|
const imgs = (n.content_image?.image || []).filter((i) => i?.url);
|
|
thumbnail = imgs.sort((a, b) => (b.width || 0) - (a.width || 0))[0]?.url;
|
|
} catch {}
|
|
// Chaîne : 1ère ligne des metadata_rows, sinon label a11y "Go to channel X"
|
|
let uploaderName;
|
|
try {
|
|
const rows = meta.metadata?.metadata_rows || [];
|
|
const first = rows[0]?.metadata_parts?.[0]?.text;
|
|
uploaderName = first?.text ?? (typeof first === 'string' ? first : undefined);
|
|
} catch {}
|
|
if (!uploaderName) {
|
|
const a11y = String(meta.image?.a11y_label || '');
|
|
const m = a11y.match(/^(?:go to channel|aller sur la cha[îi]ne)\s+(.+)$/i);
|
|
if (m) uploaderName = m[1].trim();
|
|
}
|
|
// Vues : 2e ligne ("57K", "1,2 M vues"...), durée : label a11y
|
|
let views;
|
|
try {
|
|
const rows = meta.metadata?.metadata_rows || [];
|
|
const second = rows[1]?.metadata_parts?.[0]?.text;
|
|
views = parseViewsText(second?.text ?? second);
|
|
} catch {}
|
|
const duration = parseDurationLabel(n.renderer_context?.accessibility_context?.label);
|
|
return {
|
|
title: String(title),
|
|
id,
|
|
url: `https://www.youtube.com/watch?v=${id}`,
|
|
thumbnail,
|
|
uploaderName,
|
|
type: 'video',
|
|
...(duration !== undefined ? { duration } : {}),
|
|
...(views !== undefined ? { views } : {}),
|
|
...(uploaderName ? { channelHandle: String(uploaderName) } : {}),
|
|
...(ct === 'SHORT' ? { isShort: true } : {}),
|
|
...(ct === 'LIVE' ? { isLive: true } : {}),
|
|
};
|
|
} catch { return null; }
|
|
}
|
|
|
|
export function mapNodes(nodes) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const n of nodes || []) {
|
|
const m = mapVideoNode(n);
|
|
if (!m || seen.has(m.id)) continue;
|
|
seen.add(m.id);
|
|
out.push(m);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Aplatit un nœud InnerTube (YTNode) en ses feuilles.
|
|
* `Shelf.content` peut être une `Grid`/`Shelf` imbriquée : les Shorts d'une
|
|
* recherche « Shorts » vivent là, hors de `feed.videos`.
|
|
*/
|
|
function flattenNodes(node, depth = 0, out = []) {
|
|
if (!node || depth > 5) return out;
|
|
if (Array.isArray(node)) {
|
|
for (const n of node) flattenNodes(n, depth, out);
|
|
return out;
|
|
}
|
|
if (typeof node !== 'object') return out;
|
|
let descended = false;
|
|
for (const key of ['contents', 'items', 'content']) {
|
|
const child = node[key];
|
|
if (child && typeof child === 'object') {
|
|
descended = true;
|
|
flattenNodes(child, depth + 1, out);
|
|
}
|
|
}
|
|
if (!descended) out.push(node);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Vidéos contenues dans les `Shelf` d'un feed de recherche (Shorts/clips).
|
|
* `feed.videos` ne regarde que les rendus directs, donc une recherche
|
|
* `type=shorts` y renvoie 0 sans ce complément.
|
|
* Pur et testable offline.
|
|
*/
|
|
export function mapShelfNodes(feed) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
try {
|
|
const results = Array.isArray(feed?.results) ? Array.from(feed.results) : [];
|
|
for (const node of results) {
|
|
const type = String(node?.type || '');
|
|
if (!/shelf/i.test(type)) continue;
|
|
for (const leaf of flattenNodes(node.content ?? node)) {
|
|
const mapped = mapVideoNode(leaf);
|
|
if (!mapped || seen.has(mapped.id)) continue;
|
|
seen.add(mapped.id);
|
|
out.push(mapped);
|
|
}
|
|
}
|
|
} catch { return out; }
|
|
return out;
|
|
}
|
|
|
|
// Chaîne de continuations en mémoire : q_hash -> { feed, pages }.
|
|
// (SQLite persiste les pages déjà servies ; la chaîne évite de rejouer les pages 1..N-1.)
|
|
const chainCache = new Map();
|
|
const CHAIN_MAX = 50;
|
|
function chainGet(k) { const h = chainCache.get(k); if (h) { chainCache.delete(k); chainCache.set(k, h); } return h || null; }
|
|
function chainSet(k, v) {
|
|
if (chainCache.has(k)) chainCache.delete(k);
|
|
chainCache.set(k, v);
|
|
while (chainCache.size > CHAIN_MAX) { const o = chainCache.keys().next().value; if (o === undefined) break; chainCache.delete(o); }
|
|
}
|
|
export function innertubeChainStats() { return { chains: chainCache.size, max: CHAIN_MAX }; }
|
|
|
|
function sortParam(sort) {
|
|
const s = String(sort || 'relevance').toLowerCase();
|
|
if (s === 'date') return 'upload_date';
|
|
if (s === 'views') return 'view_count';
|
|
return 'relevance';
|
|
}
|
|
|
|
/**
|
|
* Recherche InnerTube avec vraie pagination (continuations).
|
|
* Page 1 ~35-40 vidéos ; pages suivantes via getContinuation() fusionnées (façon SmartTube).
|
|
* `opts.filters` : { type, duration, period, sort } — traduits en filtres
|
|
* natifs InnerTube (upload_date / type / duration / features).
|
|
*/
|
|
export async function searchViaInnerTube(q, opts = {}) {
|
|
const query = String(q || '').trim();
|
|
if (query.length < 2) return [];
|
|
const limit = Math.min(50, Math.max(1, Number(opts?.limit || 24)));
|
|
const page = Math.min(10, Math.max(1, Number(opts?.page || 1)));
|
|
const sort = String(opts?.sort || 'relevance');
|
|
const itFilters = innertubeSearchFilters(opts?.filters);
|
|
// Les Shorts d'une recherche « Shorts » sont servis dans un `Shelf`, hors de
|
|
// `feed.videos` : on complète le lot dans ce seul cas (une recherche normale
|
|
// garde exactement son comportement et son ordre actuels).
|
|
const withShelves = itFilters.type === 'shorts';
|
|
ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1;
|
|
const yt = await getSession();
|
|
// La chaîne de continuations est propre à une combinaison
|
|
// (requête + tri + filtres) : sinon on servirait les pages d'un filtre à l'autre.
|
|
const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}|${filtersCacheKey(opts?.filters)}`)}`;
|
|
let entry = chainGet(key);
|
|
let feed = entry?.feed || null;
|
|
let collected = entry?.items ? [...entry.items] : [];
|
|
let pagesDone = entry?.pages || 0;
|
|
// Note : une "page" InnerTube fait ~17-20 vidéos quel que soit `limit`.
|
|
// On charge donc des continuations jusqu'à couvrir la fenêtre demandée
|
|
// [start, start+limit[ (façon SmartTube qui remplit son écran au fil des
|
|
// continuations), au lieu d'aligner 1 page API = 1 page UI.
|
|
const need = page * limit;
|
|
const harvest = (f) => {
|
|
const direct = mapNodes(f.videos);
|
|
if (!withShelves) return direct;
|
|
const extra = mapShelfNodes(f).filter((m) => !direct.some((d) => d.id === m.id));
|
|
return direct.concat(extra);
|
|
};
|
|
try {
|
|
if (!feed) {
|
|
feed = await yt.search(query, { ...itFilters, sort_by: sortParam(sort) });
|
|
collected = harvest(feed);
|
|
pagesDone = 1;
|
|
}
|
|
let guard = 0;
|
|
while (collected.length < need && feed?.has_continuation && guard < 12) {
|
|
guard++;
|
|
feed = await feed.getContinuation();
|
|
const fresh = harvest(feed).filter((m) => !collected.some((c) => c.id === m.id));
|
|
collected = collected.concat(fresh);
|
|
pagesDone++;
|
|
if (!fresh.length) break;
|
|
}
|
|
} catch (e) {
|
|
if (e?.code === 'yt_innertube_unavailable') throw e;
|
|
throw Object.assign(new Error(`InnerTube search failed: ${String(e?.message || e).slice(0, 160)}`), { ytStatus: 502, code: 'yt_innertube_failed' });
|
|
}
|
|
if (feed) chainSet(key, { feed, items: collected, pages: pagesDone });
|
|
const start = (page - 1) * limit;
|
|
return collected.slice(start, start + limit);
|
|
}
|
|
|
|
/**
|
|
* Vidéos connexes façon SmartTube (endpoint watch-next) : ce qui alimente
|
|
* la colonne "À suivre" sous le lecteur.
|
|
*/
|
|
export async function getRelatedViaInnerTube(videoId, limit = 24) {
|
|
const id = String(videoId || '').trim();
|
|
if (!id) return [];
|
|
const n = Math.min(50, Math.max(1, Number(limit || 24)));
|
|
ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1;
|
|
const yt = await getSession();
|
|
try {
|
|
const info = await yt.getInfo(id);
|
|
const feed = info?.watch_next_feed;
|
|
const arr = Array.isArray(feed) ? feed : (feed ? Array.from(feed) : []);
|
|
return mapNodes(arr).filter((m) => m.id !== id).slice(0, n);
|
|
} catch (e) {
|
|
if (e?.code === 'yt_innertube_unavailable') throw e;
|
|
throw Object.assign(new Error(`InnerTube related failed: ${String(e?.message || e).slice(0, 160)}`), { ytStatus: 502, code: 'yt_innertube_failed' });
|
|
}
|
|
}
|
|
|
|
// -------------------- Transcripts via InnerTube --------------------
|
|
// `getInfo().captions.caption_tracks[]` expose les mêmes URLs timedtext que
|
|
// yt-dlp découvre via un dump complet (`subtitles`/`automatic_captions`).
|
|
// On adapte leur forme vers le format yt-dlp pour réutiliser pickTrack(),
|
|
// orderedTracks() et parseTrackText() de transcript.mjs sans les toucher.
|
|
|
|
function normCaptionLang(code) {
|
|
return String(code || '').trim().toLowerCase().replace(/_/g, '-');
|
|
}
|
|
|
|
/**
|
|
* Adapte des caption tracks InnerTube brutes vers un pseudo dump yt-dlp
|
|
* `{ subtitles, automatic_captions }` (kind 'asr' = auto-généré).
|
|
* Pur et testable offline.
|
|
*/
|
|
export function mapCaptionTracks(captionTracks) {
|
|
const subtitles = {};
|
|
const automatic_captions = {};
|
|
for (const t of captionTracks || []) {
|
|
const base = String(t?.base_url || '');
|
|
if (!base) continue;
|
|
const lang = normCaptionLang(t?.language_code) || 'und';
|
|
const name = t?.name?.text ?? (typeof t?.name === 'string' ? t.name : lang);
|
|
// Ne jamais dupliquer `fmt=` : une URL signée dont on altère la query
|
|
// voit sa `signature` invalidée (YouTube répond 429/Sorry).
|
|
const trackUrl = /[?&]fmt=/i.test(base)
|
|
? base
|
|
: `${base}${base.includes('?') ? '&' : '?'}fmt=json3`;
|
|
const track = { url: trackUrl, ext: 'json3', name: String(name) };
|
|
const dict = t?.kind === 'asr' ? automatic_captions : subtitles;
|
|
if (!Array.isArray(dict[lang])) dict[lang] = [];
|
|
if (!dict[lang].some((x) => x.url === track.url)) dict[lang].push(track);
|
|
}
|
|
const languages = Array.from(new Set([...Object.keys(subtitles), ...Object.keys(automatic_captions)]));
|
|
const trackCount = Object.values(subtitles).concat(Object.values(automatic_captions))
|
|
.reduce((n, arr) => n + arr.length, 0);
|
|
return { subtitles, automatic_captions, languages, trackCount };
|
|
}
|
|
|
|
/**
|
|
* Découvre les pistes de sous-titres d'une vidéo via InnerTube (0 quota,
|
|
* pas de spawn yt-dlp). Retourne un pseudo dump yt-dlp prêt pour pickTrack().
|
|
*/
|
|
export async function getCaptionTracksViaInnerTube(videoId) {
|
|
const id = String(videoId || '').trim();
|
|
if (!id) return { subtitles: {}, automatic_captions: {}, languages: [], trackCount: 0 };
|
|
ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1;
|
|
const yt = await getSession();
|
|
try {
|
|
const info = await yt.getInfo(id);
|
|
const raw = info?.captions?.caption_tracks || [];
|
|
return mapCaptionTracks(Array.isArray(raw) ? raw : Array.from(raw));
|
|
} catch (e) {
|
|
if (e?.code === 'yt_innertube_unavailable') throw e;
|
|
throw Object.assign(new Error(`InnerTube captions failed: ${String(e?.message || e).slice(0, 160)}`), { ytStatus: 502, code: 'yt_innertube_failed' });
|
|
}
|
|
}
|