From 036d3a80b80a15735811f5fba548e2816658115d Mon Sep 17 00:00:00 2001 From: ashley Date: Mon, 29 Sep 2025 08:49:10 +0200 Subject: [PATCH] fix stuff + add stuff --- src/libpoketube/libpoketube-youtube-player.js | 696 +++++++++++------- 1 file changed, 449 insertions(+), 247 deletions(-) diff --git a/src/libpoketube/libpoketube-youtube-player.js b/src/libpoketube/libpoketube-youtube-player.js index 64ac3345..048e6f4d 100644 --- a/src/libpoketube/libpoketube-youtube-player.js +++ b/src/libpoketube/libpoketube-youtube-player.js @@ -10,10 +10,138 @@ const getdislikes = require("../libpoketube/libpoketube-dislikes.js"); const getColors = require("get-image-colors"); const config = require("../../config.json"); +class BackendScheduler { + constructor(opts = {}) { + this.buckets = new Map(); // key -> {tokens, lastRefill, rate, burst, cooldownUntil} + this.queues = new Map(); // key -> [resolveFns] + this.opts = { + defaultRatePerSec: opts.defaultRatePerSec || 4, // default steady rate + defaultBurst: opts.defaultBurst || 8, // allowed burst + refillIntervalMs: opts.refillIntervalMs || 250, + cooldownDefaultMs: opts.cooldownDefaultMs || 2000, + maxQueueSize: opts.maxQueueSize || 200, + ...opts, + }; + + // periodic refill + this._refillTimer = setInterval(() => this._refillAll(), this.opts.refillIntervalMs); + this._refillTimer.unref?.(); + } + + _getBucket(key) { + if (!this.buckets.has(key)) { + this.buckets.set(key, { + tokens: this.opts.defaultBurst, + lastRefill: Date.now(), + rate: this.opts.defaultRatePerSec, + burst: this.opts.defaultBurst, + cooldownUntil: 0, + }); + } + return this.buckets.get(key); + } + + _refillAll() { + const now = Date.now(); + for (const [k, b] of this.buckets) { + if (b.cooldownUntil > now) continue; + const elapsed = now - b.lastRefill; + if (elapsed <= 0) continue; + const add = (elapsed / 1000) * b.rate; + if (add > 0) { + b.tokens = Math.min(b.burst, b.tokens + add); + b.lastRefill = now; + } + } + // drain small queues if tokens available + for (const [k, q] of this.queues) { + const b = this.buckets.get(k); + if (!b) continue; + while (q.length && b.tokens >= 1 && b.cooldownUntil <= now) { + b.tokens -= 1; + const fn = q.shift(); + fn(); // resolve queued waiter + } + } + } + + // request permission to call backend `key` within `timeoutMs`. + // resolves when caller may proceed, or rejects on timeout. + acquire(key, timeoutMs = 1000) { + const bucket = this._getBucket(key); + const now = Date.now(); + + // if in cooldown, wait until cooldown passes (or timeout) + if (bucket.cooldownUntil > now) { + return new Promise((res, rej) => { + const wait = bucket.cooldownUntil - now; + if (wait > timeoutMs) return rej(new Error("acquire timeout (cooldown)")); + const t = setTimeout(() => res(), wait); + // no further cleanup here; caller will proceed after resolve + }); + } + + // if token available, take one immediately + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + return Promise.resolve(); + } + + // otherwise enqueue up to maxQueueSize + const q = this.queues.get(key) || []; + if (q.length >= this.opts.maxQueueSize) { + return Promise.reject(new Error("acquire queue full")); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // remove from queue if still present + const arr = this.queues.get(key); + if (arr) { + const idx = arr.indexOf(fn); + if (idx !== -1) arr.splice(idx, 1); + } + reject(new Error("acquire timeout")); + }, timeoutMs); + + const fn = () => { + clearTimeout(timer); + resolve(); + }; + + q.push(fn); + this.queues.set(key, q); + }); + } + + // flag backend into cooldown (on 429). Accepts ms or parsed Retry-After + setCooldown(key, ms) { + const b = this._getBucket(key); + const until = Date.now() + Math.max(0, ms || this.opts.cooldownDefaultMs); + // progressively increase if already in cooldown + b.cooldownUntil = Math.max(b.cooldownUntil, until); + // reduce tokens to zero to avoid immediate retries + b.tokens = 0; + } + + // convenience: adjust rate/burst for a backend + configure(key, { ratePerSec, burst } = {}) { + const b = this._getBucket(key); + if (ratePerSec != null) b.rate = ratePerSec; + if (burst != null) { b.burst = burst; b.tokens = Math.min(b.tokens, b.burst); } + } + + stop() { + clearInterval(this._refillTimer); + this.buckets.clear(); + this.queues.clear(); + } +} + class InnerTubePokeVidious { constructor(config) { this.config = config; this.cache = {}; + this.inflight = new Map(); // dedupe in-flight video requests by id this.language = "hl=en-US"; this.param = "2AMB"; this.param_legacy = "CgIIAdgDAQ%3D%3D"; @@ -28,6 +156,19 @@ class InnerTubePokeVidious { this.region = "region=US"; this.sqp = "-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBy_x4UUHLNDZtJtH0PXeQGoRFTgw"; + + // scheduler instance shared across calls + this.scheduler = new BackendScheduler({ + // tune rates here if needed + defaultRatePerSec: (config.backendRatePerSec) || 6, + defaultBurst: (config.backendBurst) || 12, + refillIntervalMs: 200, + cooldownDefaultMs: 2200, + maxQueueSize: 400, + }); + + // small stagger when trying fallback to avoid simultaneous double hits + this.fallbackStaggerMs = config.fallbackStaggerMs ?? 80; } getJson(str) { @@ -42,268 +183,99 @@ class InnerTubePokeVidious { return obj && "authorId" in obj; } - // safe base64 helper so btoa isn't required in Node toBase64(str) { if (typeof btoa !== "undefined") return btoa(str); return Buffer.from(String(str)).toString("base64"); } - async getYouTubeApiVideo(f, v, contentlang, contentregion) { + // parse Retry-After header to ms + _parseRetryAfterMs(hdr) { + if (!hdr) return null; + const s = String(hdr).trim(); + const n = Number(s); + if (Number.isFinite(n)) return Math.max(0, n * 1000 | 0); + const t = Date.parse(s); + if (!Number.isNaN(t)) return Math.max(0, t - Date.now()); + return null; + } + + // streamlined fetch-with-retry that consults scheduler before calling. + // - always respects an overall maxRetryTime (ms) + async _fetchWithRetryAndSchedule(url, backendKey, options = {}, maxRetryTime = 5000) { const { fetch } = await import("undici"); + const RETRYABLE = new Set([429, 500, 502, 503, 504]); + const PER_TRY_TIMEOUT_MS = 1100; + const QUICK_RETRY_MS = 80; - if (!v) { - this.initError("Missing video ID", null); - return { error: true, message: "No video ID provided" }; - } + const start = Date.now(); + let lastError = null; - // simple 1-hour cache - if (this.cache[v] && Date.now() - this.cache[v].timestamp < 3600000) { - return this.cache[v].result; - } - - const headers = { - "User-Agent": this.useragent, - }; - - - const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => { - const RETRYABLE = new Set([429, 500, 502, 503, 504]); - const PER_TRY_TIMEOUT_MS = 1200; // fail fast - const FIXED_RETRY_DELAY_MS = 120; // quick retry gap - const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - - const parseRetryAfter = (hdr) => { - if (!hdr) return null; - const s = String(hdr).trim(); - const numeric = Number(s); - if (Number.isFinite(numeric)) return Math.max(0, numeric * 1000 | 0); - const when = Date.parse(s); - if (!Number.isNaN(when)) return Math.max(0, when - Date.now()); - return null; - }; - - const callerSignal = options?.signal || null; - - const attemptFetch = async (timeoutMs) => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(new Error("Fetch attempt timed out")), timeoutMs > 0 ? timeoutMs : 1); - const onCallerAbort = () => controller.abort(callerSignal?.reason || new Error("Aborted by caller")); - if (callerSignal) { - if (callerSignal.aborted) { - controller.abort(callerSignal.reason || new Error("Aborted by caller")); - } else { - callerSignal.addEventListener("abort", onCallerAbort, { once: true }); - } - } - try { - return await fetch(url, { - ...options, - headers: { - ...options?.headers, - ...headers, - }, - signal: controller.signal, - }); - } finally { - clearTimeout(timer); - if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort); - } - }; - - const start = Date.now(); - let lastErr = null; - - while (true) { - const elapsed = Date.now() - start; - const remaining = maxRetryTime - elapsed; - if (remaining <= 0) { - const err = new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`); - err.cause = lastErr; - throw err; - } - - const perTryTimeout = Math.min(PER_TRY_TIMEOUT_MS, Math.max(100, remaining - 50)); - - try { - const res = await attemptFetch(perTryTimeout); - if (res.ok) return res; - if (!RETRYABLE.has(res.status)) return res; - - // retryable status -> respect Retry-After if present, otherwise short fixed delay - const ra = parseRetryAfter(res.headers.get("Retry-After")); - const waitMs = ra != null ? Math.max(50, Math.min(ra, remaining - 10)) : Math.min(FIXED_RETRY_DELAY_MS, Math.max(0, remaining - 10)); - if (waitMs <= 0) throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms (window depleted)`); - - this?.initError?.(`Retrying fetch for ${url}`, res.status); - await sleep(waitMs); - lastErr = new Error(`HTTP ${res.status}`); - continue; - } catch (err) { - if (callerSignal && callerSignal.aborted) throw err; - lastErr = err; - const remaining2 = maxRetryTime - (Date.now() - start); - if (remaining2 <= 0) throw lastErr; - // short fixed pause, then retry quickly - await sleep(Math.min(FIXED_RETRY_DELAY_MS, Math.max(20, remaining2 - 10))); - continue; - } - } - }; - - const minute = new Date().getMinutes(); - const hour = new Date().getHours(); - - const pattern = ["fallback", "normal", "fallback", "normal", "normal", "fallback"]; - const twoHourIndex = Math.floor(hour / 2) % pattern.length; - const currentPreference = pattern[twoHourIndex]; - - const inFallbackWindow = minute >= 20 && minute < 30; - - const primaryUrl = `${this.config.invapi}/videos/${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64(Date.now())}`; - const fallbackUrl = `${this.config.inv_fallback}${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64(Date.now())}`; - - const preferFallbackPrimary = currentPreference === "fallback"; - const chooseFirst = preferFallbackPrimary ? (inFallbackWindow ? fallbackUrl : primaryUrl) : (inFallbackWindow ? primaryUrl : fallbackUrl); - const chooseSecond = chooseFirst === primaryUrl ? fallbackUrl : primaryUrl; - - // Race strategy: start both quickly; return the first OK response and abort the other. - const fetchPrefer = async (urlA, urlB, maxRetryTime = 5000) => { - // controllers so we can abort the loser - const acA = new AbortController(); - const acB = new AbortController(); - - // wrapped fetches return an object { url, res } on success or { url, err } on failure - const wrapped = (url, ac) => - fetchWithRetry(url, { signal: ac.signal }, maxRetryTime) - .then((res) => ({ url, res })) - .catch((err) => ({ url, err })); - - // start both in parallel immediately (fast path) - const pA = wrapped(urlA, acA); - const pB = wrapped(urlB, acB); - - // Wait for either to succeed with ok; otherwise prefer the first fulfilled response. - // We'll poll settled promises as they arrive instead of waiting both. - const settled = await Promise.allSettled([pA, pB]); - - // 1) prefer an OK response - for (const s of settled) { - if (s.status === "fulfilled" && s.value && s.value.res && s.value.res.ok) { - if (s.value.url === urlA) acB.abort(); - else acA.abort(); - return s.value.res; - } + while (true) { + const elapsed = Date.now() - start; + const remaining = maxRetryTime - elapsed; + if (remaining <= 0) { + const e = new Error(`fetch ${url} failed after ${maxRetryTime}ms`); + e.cause = lastError; + throw e; } - // 2) prefer any fulfilled response (even non-OK) - for (const s of settled) { - if (s.status === "fulfilled" && s.value && s.value.res) { - if (s.value.url === urlA) acB.abort(); - else acA.abort(); - return s.value.res; - } + // acquire slot for backend (short timeout to bail quickly) + try { + await this.scheduler.acquire(backendKey, Math.min(600, remaining)); + } catch (err) { + // scheduler blocked; retry loop until overall window exhausted + lastError = err; + await new Promise((r) => setTimeout(r, Math.min(QUICK_RETRY_MS, Math.max(10, remaining - 20)))); + continue; } - // 3) otherwise throw first error we have - for (const s of settled) { - if (s.status === "fulfilled" && s.value && s.value.err) throw s.value.err; - } - - throw new Error("Both fetches failed"); - }; - - try { - // fetch comments in parallel with a smaller window - const invCommentsPromise = fetchWithRetry( - `${this.config.invapi}/comments/${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64(Date.now())}`, - {}, - 2500 - ) - .then((r) => r?.text()) - .catch((err) => { - this.initError("Comments fetch error", err); - return null; - }); - - // video info: pick whichever responds first (primary/fallback ordering preserved) - const videoInfoPromise = (async () => { - const r = await fetchPrefer(chooseFirst, chooseSecond, 5000); - return await r.text(); - })(); - - const [invComments, videoInfo] = await Promise.all([invCommentsPromise, videoInfoPromise]); - - const comments = this.getJson(invComments); - const vid = this.getJson(videoInfo); - - if (!vid) { - this.initError("Video info missing/unparsable", v); - return { - error: true, - message: - "Sorry nya, we couldn't find any information about that video qwq", - }; - } - - if (this.checkUnexistingObject(vid)) { - const dislikePromise = (async () => { - try { - return await getdislikes(v); - } catch (err) { - this.initError("Dislike API error", err); - return { engagement: null }; - } - })(); - - const colorPromise = (async () => { - try { - // sqp is a URL parameter used by YouTube thumbnail/image servers - // to request a specific scale, crop or quality profile (base64-encoded), - // controlling how the thumbnail is sized or compressed. - const imgUrl = `https://i.ytimg.com/vi/${v}/hqdefault.jpg?sqp=${this.sqp}`; - const p = getColors(imgUrl); - const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("Color extraction timeout")), 1000)); - const palette = await Promise.race([p, timeout]); - if (Array.isArray(palette) && palette[0] && palette[1]) { - return [palette[0].hex(), palette[1].hex()]; - } - return null; - } catch (err) { - this.initError("Thumbnail color extraction error", err); - return null; - } - })(); - - const [returnyoutubedislikesapi, paletteResult] = await Promise.all([dislikePromise, colorPromise]); - - let color = "#0ea5e9"; - let color2 = "#111827"; - if (Array.isArray(paletteResult) && paletteResult[0]) { - color = paletteResult[0] || color; - color2 = paletteResult[1] || color2; - } - - this.cache[v] = { - result: { - vid, - comments, - channel_uploads: " ", - engagement: returnyoutubedislikesapi?.engagement ?? null, - wiki: "", - desc: "", - color, - color2, + // make attempt with small per-try timeout + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(new Error("attempt timeout")), Math.min(PER_TRY_TIMEOUT_MS, Math.max(80, remaining - 50))); + try { + const res = await fetch(url, { + ...options, + headers: { + ...(options?.headers || {}), + "User-Agent": this.useragent, }, - timestamp: Date.now(), - }; + signal: ac.signal, + }); + if (res.ok) { + clearTimeout(timer); + return res; + } - return this.cache[v].result; - } else { - this.initError(vid, `ID: ${v}`); + // handle 429 specially: put backend into cooldown based on Retry-After or quick default + if (res.status === 429) { + const ra = this._parseRetryAfterMs(res.headers.get("Retry-After")) || 1500; + this.scheduler.setCooldown(backendKey, ra); + lastError = new Error(`HTTP 429`); + // small delay then retry loop + await new Promise((r) => setTimeout(r, Math.min(ra, Math.max(60, remaining - 20)))); + clearTimeout(timer); + continue; + } + + // non-retryable pass-through + if (!RETRYABLE.has(res.status)) { + clearTimeout(timer); + return res; + } + + // retryable status: quick wait then retry + lastError = new Error(`HTTP ${res.status}`); + await new Promise((r) => setTimeout(r, Math.min(QUICK_RETRY_MS, Math.max(20, remaining - 20)))); + clearTimeout(timer); + continue; + } catch (err) { + // aborted by signal or network error + lastError = err; + // if fetch was aborted because scheduler aborts, treat as retryable + await new Promise((r) => setTimeout(r, Math.min(QUICK_RETRY_MS, Math.max(10, remaining - 20)))); + clearTimeout(timer); + continue; } - } catch (error) { - this.initError(`Error getting video ${v}`, error); - return { error: true, message: "Fetch error", detail: String(error) }; } } @@ -315,8 +287,238 @@ class InnerTubePokeVidious { } initError(context, error) { + // log with context console.log("[LIBPT CORE ERROR]", context, error?.stack || error || ""); } + + // main public method + async getYouTubeApiVideo(f, v, contentlang = "en-US", contentregion = "US") { + // quick validation + if (!v) { + this.initError("Missing video ID", null); + return { error: true, message: "No video ID provided" }; + } + if (!this.isvalidvideo(v)) { + this.initError("Invalid video id", v); + return { error: true, message: "Invalid video id" }; + } + + // cache hit + const cached = this.cache[v]; + if (cached && Date.now() - cached.timestamp < 3600000) { + return cached.result; + } + + // dedupe simultaneous requests for same id + if (this.inflight.has(v)) { + return this.inflight.get(v); + } + + const promise = (async () => { + const { fetch } = await import("undici"); + + const minute = new Date().getMinutes(); + const hour = new Date().getHours(); + + // pattern to bias primary vs fallback across 2-hour blocks + const pattern = ["fallback", "normal", "fallback", "normal", "normal", "fallback"]; + const twoHourIndex = Math.floor(hour / 2) % pattern.length; + const currentPreference = pattern[twoHourIndex]; + + // explicit fallback window on :20 - :29 + const inFallbackWindow = minute >= 20 && minute < 30; + + const primaryUrl = `${this.config.invapi}/videos/${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64(Date.now())}`; + const fallbackUrl = `${this.config.inv_fallback}${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64(Date.now())}`; + + const preferFallbackPrimary = currentPreference === "fallback"; + const chooseFirst = preferFallbackPrimary ? (inFallbackWindow ? fallbackUrl : primaryUrl) : (inFallbackWindow ? primaryUrl : fallbackUrl); + const chooseSecond = chooseFirst === primaryUrl ? fallbackUrl : primaryUrl; + + const backendKeyA = new URL(chooseFirst).origin; + const backendKeyB = new URL(chooseSecond).origin; + + // comments fetch started in parallel but with small window + const commentsPromise = this._fetchWithRetryAndSchedule( + `${this.config.invapi}/comments/${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64(Date.now())}`, + new URL(this.config.invapi).origin, + {}, + 2500 + ) + .then((r) => r?.text()) + .catch((err) => { + this.initError("Comments fetch error", err); + return null; + }); + + // strategy: start primary immediately. start fallback after fallbackStaggerMs if primary still pending. + // both flows use scheduler to avoid rate spikes. + const startPrimary = async () => { + try { + const r = await this._fetchWithRetryAndSchedule(chooseFirst, backendKeyA, {}, 5000); + return { res: r, url: chooseFirst }; + } catch (err) { + return { err, url: chooseFirst }; + } + }; + + const startFallback = async () => { + try { + const r = await this._fetchWithRetryAndSchedule(chooseSecond, backendKeyB, {}, 5000); + return { res: r, url: chooseSecond }; + } catch (err) { + return { err, url: chooseSecond }; + } + }; + + // kick off primary + const pPrimary = startPrimary(); + + // schedule fallback with a small stagger + const fallbackTimer = new Promise((res) => + setTimeout(() => res(true), this.fallbackStaggerMs) + ); + + // race logic: wait for whichever returns OK first, but prefer not to fire fallback if primary finished. + const raceResult = await (async () => { + // wait for either primary to finish quickly, or stagger timeout + const first = await Promise.race([pPrimary, fallbackTimer]); + + if (first && first.res === undefined && first.err === undefined) { + // reached fallback timer: start fallback while primary may still be running + const pFallback = startFallback(); + // wait for first successful OK from either + const settled = await Promise.allSettled([pPrimary, pFallback]); + // prefer OK + for (const s of settled) { + if (s.status === "fulfilled" && s.value && s.value.res && s.value.res.ok) return s.value; + } + // otherwise pick first fulfilled with res + for (const s of settled) { + if (s.status === "fulfilled" && s.value && s.value.res) return s.value; + } + // otherwise return first error + for (const s of settled) { + if (s.status === "fulfilled" && s.value && s.value.err) return s.value; + } + // if still nothing, throw aggregate + throw new Error("Both upstreams failed"); + } else { + // primary finished before fallback timer + if (first && first.res) { + return first; + } + // primary returned error object + // start fallback immediately + const pFallback = startFallback(); + const settled = await Promise.allSettled([pPrimary, pFallback]); + for (const s of settled) { + if (s.status === "fulfilled" && s.value && s.value.res && s.value.res.ok) return s.value; + } + for (const s of settled) { + if (s.status === "fulfilled" && s.value && s.value.res) return s.value; + } + for (const s of settled) { + if (s.status === "fulfilled" && s.value && s.value.err) return s.value; + } + throw new Error("Both upstreams failed"); + } + })(); + + // if result is an error object, surface small message + if (raceResult.err) { + this.initError("Primary+Fallback fetch error", raceResult.err); + throw raceResult.err; + } + + // got a Response-like object + const r = raceResult.res; + const videoInfoText = await r.text().catch((e) => { + this.initError("Reading response text failed", e); + return null; + }); + + const commentsText = await commentsPromise; + const comments = this.getJson(commentsText); + const vid = this.getJson(videoInfoText); + + if (!vid) { + this.initError("Video info missing/unparsable", v); + return { + error: true, + message: "Couldn't parse video info", + }; + } + + if (this.checkUnexistingObject(vid)) { + // fill cache quickly with defaults so response is fast + const baseResult = { + vid, + comments, + channel_uploads: " ", + engagement: null, + wiki: "", + desc: "", + color: "#0ea5e9", + color2: "#111827", + }; + + this.cache[v] = { + result: baseResult, + timestamp: Date.now(), + }; + + // run heavy/slow tasks async: dislikes + color extraction update cache when done + (async () => { + try { + // dislikes (may be slow) + let dislikesRes = { engagement: null }; + try { + dislikesRes = await getdislikes(v); + } catch (err) { + this.initError("Dislike API error (async)", err); + } + + // color extraction with short timeout + try { + const imgUrl = `https://i.ytimg.com/vi/${v}/hqdefault.jpg?sqp=${this.sqp}`; + const p = getColors(imgUrl); + const to = new Promise((_, rej) => setTimeout(() => rej(new Error("color timeout")), 1000)); + const palette = await Promise.race([p, to]).catch(() => null); + if (Array.isArray(palette) && palette[0] && palette[1]) { + baseResult.color = palette[0].hex(); + baseResult.color2 = palette[1].hex(); + } + } catch (err) { + this.initError("Color extraction error (async)", err); + } + + // update engagement & cache timestamp + baseResult.engagement = dislikesRes?.engagement ?? baseResult.engagement; + this.cache[v] = { + result: baseResult, + timestamp: Date.now(), + }; + } catch (err) { + this.initError("Async post-processing error", err); + } + })(); + + return baseResult; + } else { + this.initError(vid, `ID: ${v}`); + } + })(); + + // store and clear inflight when done + this.inflight.set(v, promise); + try { + const res = await promise; + return res; + } finally { + this.inflight.delete(v); + } + } } const pokeTubeApiCore = new InnerTubePokeVidious({