From 234169fc1ca16a76feb9bc44cde5f7e0ef0dbf20 Mon Sep 17 00:00:00 2001 From: ashley Date: Sun, 28 Sep 2025 00:48:03 +0200 Subject: [PATCH] test something --- src/libpoketube/libpoketube-core.js | 355 ++++++++++++++++------------ 1 file changed, 200 insertions(+), 155 deletions(-) diff --git a/src/libpoketube/libpoketube-core.js b/src/libpoketube/libpoketube-core.js index 68157d76..a58f03e0 100644 --- a/src/libpoketube/libpoketube-core.js +++ b/src/libpoketube/libpoketube-core.js @@ -59,179 +59,224 @@ class InnerTubePokeVidious { "User-Agent": this.useragent, }; -// Retries only within a 8s window that starts AFTER the first 500/502. -// Fast path: one plain fetch with no extra timers/signals unless 500/502 occurs. -const fetchWithRetry = async (url, options = {}, maxRetryTime = 8000) => { - let lastError; + // Retries only within a 5s window that starts AFTER the first 500/502. + // Fast path: one plain fetch with no extra timers/signals unless 500/502 occurs. + const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => { + let lastError; - // Trigger statuses that arm the retry window - const TRIGGER = 500 | 502; // bitwise trick for branch hints; DO NOT rely on value - const isTrigger = (s) => (s === 500 || s === 502); + // Trigger statuses that arm the retry window + const TRIGGER = 500 | 502; // bitwise trick for branch hints; DO NOT rely on value + const isTrigger = (s) => (s === 500 || s === 502); - // Once armed, these are retryable (plus network errors) - const RETRYABLE = new Set([429, 500, 502, 503, 504]); + // Once armed, these are retryable (plus network errors) + const RETRYABLE = new Set([429, 500, 502, 503, 504]); - // Backoff (decorrelated jitter) — gentle defaults - const MIN_DELAY_MS = 150; - const BASE_DELAY_MS = 250; - const MAX_DELAY_MS = 2000; - const JITTER_FACTOR = 3; + // Backoff (decorrelated jitter) — gentle defaults + const MIN_DELAY_MS = 150; + const BASE_DELAY_MS = 250; + const MAX_DELAY_MS = 2000; + const JITTER_FACTOR = 3; - // Per-attempt timeout (only used after window is armed) - const PER_TRY_TIMEOUT_MS = 2000; + // Per-attempt timeout (only used after window is armed) + const PER_TRY_TIMEOUT_MS = 2000; - const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - // Parse Retry-After (delta-seconds or HTTP-date) - const parseRetryAfter = (hdr) => { - if (!hdr) return null; - const s = String(hdr).trim(); - const delta = Number(s); - if (Number.isFinite(delta)) return Math.max(0, (delta * 1000) | 0); - const when = Date.parse(s); - if (!Number.isNaN(when)) return Math.max(0, when - Date.now()); - return null; - }; + // Parse Retry-After (delta-seconds or HTTP-date) + const parseRetryAfter = (hdr) => { + if (!hdr) return null; + const s = String(hdr).trim(); + const delta = Number(s); + if (Number.isFinite(delta)) return Math.max(0, (delta * 1000) | 0); + const when = Date.parse(s); + if (!Number.isNaN(when)) return Math.max(0, when - Date.now()); + return null; + }; - // FAST PATH: single plain fetch (no AbortController, no timeout, no extra work) - let res; - try { - res = await fetch(url, { - ...options, - headers: { - ...options?.headers, - ...headers, - }, - }); - } catch (err) { - // Network error BEFORE any 500/502 trigger → surface immediately (no retries) - this?.initError?.(`Fetch error for ${url}`, err); - throw err; - } - - if (res.ok) return res; - - // Not a trigger? return immediately (no retry window, no delays) - if (!isTrigger(res.status)) return res; - - // SLOW PATH (only after a 500/502): arm the retry window - const retryStart = Date.now(); - let delayMs = BASE_DELAY_MS; // backoff seed - let attempt = 1; - const callerSignal = options?.signal || null; - - // Helper: one attempt with internal timeout that respects caller aborts - const attemptWithTimeout = 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); - } - }; - - // Optional short stagger before the first retry to reduce herd effects - // await sleep(50 + ((Math.random() * 150) | 0)); - - // Retry loop within the 8s window - while (true) { - const elapsed = Date.now() - retryStart; - const remaining = maxRetryTime - elapsed; - if (remaining <= 0) { - throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`); - } - - const perTryTimeout = Math.min(PER_TRY_TIMEOUT_MS, Math.max(100, remaining - 50)); - - try { - const r = await attemptWithTimeout(perTryTimeout); - if (r.ok) return r; - - if (!RETRYABLE.has(r.status)) { - // Non-retryable after window armed → return immediately - return r; + // FAST PATH: single plain fetch (no AbortController, no timeout, no extra work) + let res; + try { + res = await fetch(url, { + ...options, + headers: { + ...options?.headers, + ...headers, + }, + }); + } catch (err) { + // Network error BEFORE any 500/502 trigger → surface immediately (no retries) + this?.initError?.(`Fetch error for ${url}`, err); + throw err; } - // Respect server cooldown if provided - const retryAfterMs = parseRetryAfter(r.headers.get("Retry-After")); - let waitMs; - if (retryAfterMs != null) { - waitMs = Math.max(MIN_DELAY_MS, Math.min(retryAfterMs, Math.max(0, remaining - 10))); - } else { - // Decorrelated jitter: min(MAX, random(MIN, prev*factor)) - const next = Math.min(MAX_DELAY_MS, Math.random() * delayMs * JITTER_FACTOR); - delayMs = next < MIN_DELAY_MS ? MIN_DELAY_MS : next; - waitMs = Math.min(delayMs, Math.max(0, remaining - 10)); + if (res.ok) return res; + + // Not a trigger? return immediately (no retry window, no delays) + if (!isTrigger(res.status)) return res; + + // SLOW PATH (only after a 500/502): arm the retry window + const retryStart = Date.now(); + let delayMs = BASE_DELAY_MS; // backoff seed + let attempt = 1; + const callerSignal = options?.signal || null; + + // Helper: one attempt with internal timeout that respects caller aborts + const attemptWithTimeout = 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); + } + }; + + // Optional short stagger before the first retry to reduce herd effects + // await sleep(50 + ((Math.random() * 150) | 0)); + + // Retry loop within the 5s window + while (true) { + const elapsed = Date.now() - retryStart; + const remaining = maxRetryTime - elapsed; + if (remaining <= 0) { + throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`); + } + + const perTryTimeout = Math.min(PER_TRY_TIMEOUT_MS, Math.max(100, remaining - 50)); + + try { + const r = await attemptWithTimeout(perTryTimeout); + if (r.ok) return r; + + if (!RETRYABLE.has(r.status)) { + // Non-retryable after window armed → return immediately + return r; + } + + // Respect server cooldown if provided + const retryAfterMs = parseRetryAfter(r.headers.get("Retry-After")); + let waitMs; + if (retryAfterMs != null) { + waitMs = Math.max(MIN_DELAY_MS, Math.min(retryAfterMs, Math.max(0, remaining - 10))); + } else { + // Decorrelated jitter: min(MAX, random(MIN, prev*factor)) + const next = Math.min(MAX_DELAY_MS, Math.random() * delayMs * JITTER_FACTOR); + delayMs = next < MIN_DELAY_MS ? MIN_DELAY_MS : next; + waitMs = Math.min(delayMs, 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}`, r.status); + attempt++; + await sleep(waitMs); + continue; + } catch (err) { + // Caller aborted → surface immediately + if (callerSignal && callerSignal.aborted) throw err; + + lastError = err; + + const remaining2 = maxRetryTime - (Date.now() - retryStart); + if (remaining2 <= 0) throw lastError; + + // Backoff after network/timeout errors, too + const next = Math.min(MAX_DELAY_MS, Math.random() * delayMs * JITTER_FACTOR); + delayMs = next < MIN_DELAY_MS ? MIN_DELAY_MS : next; + const waitMs = Math.min(delayMs, Math.max(0, remaining2 - 10)); + if (waitMs <= 0) throw lastError; + + this?.initError?.(`Fetch error for ${url}`, err); + attempt++; + await sleep(waitMs); + continue; + } } + }; - if (waitMs <= 0) { - throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms (window depleted)`); + // Helper: fetch the videos endpoint but fall back to inv_fallback if primary fails or times out + const fetchVideoTextWithFallback = async (videoId, lang, region) => { + const primaryUrl = `${this.config.invapi}/videos/${videoId}?hl=${lang}®ion=${region}&h=${btoa( + Date.now() + )}`; + + // Build fallback URL carefully — allow inv_fallback to end with or without trailing slash. + // inv_fallback is expected to be something like: https://poketube.duti.dev/api/v1/videos/ + const fallbackBase = (this.config.inv_fallback || "").replace(/\/+$/, ""); + const fallbackUrl = + fallbackBase && fallbackBase.includes("/videos") + ? `${fallbackBase.replace(/\/+$/, "")}/${videoId}` + : `${fallbackBase}/${videoId}?hl=${lang}®ion=${region}&h=${btoa(Date.now())}`; + + const PRIMARY_TIMEOUT_MS = 5000; + + // Try primary within timeout window + try { + // Race the fetchWithRetry against a timeout promise + const primaryPromise = fetchWithRetry(primaryUrl, {}, 8000); + const timeoutPromise = new Promise((_, rej) => + setTimeout(() => rej(new Error("Primary videos API timed out")), PRIMARY_TIMEOUT_MS) + ); + + const r = await Promise.race([primaryPromise, timeoutPromise]); + // If we got a Response-like object, return its text (works even if r is a Response) + return await r.text(); + } catch (errPrimary) { + // Primary failed or timed out — try fallback if available + this.initError("Primary videos API failed or timed out, trying fallback", errPrimary?.stack || errPrimary); + + if (!fallbackBase) { + // No fallback configured; rethrow primary error + throw errPrimary; + } + + try { + const r2 = await fetchWithRetry(fallbackUrl, {}, 8000); + // If fallback returns a Response-like object, return its text + return await r2.text(); + } catch (errFallback) { + // Both failed — log and rethrow + this.initError("Fallback videos API failed", errFallback?.stack || errFallback); + throw errFallback; + } } - - this?.initError?.(`Retrying fetch for ${url}`, r.status); - attempt++; - await sleep(waitMs); - continue; - } catch (err) { - // Caller aborted → surface immediately - if (callerSignal && callerSignal.aborted) throw err; - - lastError = err; - - const remaining2 = maxRetryTime - (Date.now() - retryStart); - if (remaining2 <= 0) throw lastError; - - // Backoff after network/timeout errors, too - const next = Math.min(MAX_DELAY_MS, Math.random() * delayMs * JITTER_FACTOR); - delayMs = next < MIN_DELAY_MS ? MIN_DELAY_MS : next; - const waitMs = Math.min(delayMs, Math.max(0, remaining2 - 10)); - if (waitMs <= 0) throw lastError; - - this?.initError?.(`Fetch error for ${url}`, err); - attempt++; - await sleep(waitMs); - continue; - } - } -}; - + }; try { const [invComments, videoInfo] = await Promise.all([ + // Comments: only from primary invapi, same as before fetchWithRetry( `${this.config.invapi}/comments/${v}?hl=${contentlang}®ion=${contentregion}&h=${btoa( Date.now() )}` ).then((res) => res?.text()), - fetchWithRetry( - `${this.config.invapi}/videos/${v}?hl=${contentlang}®ion=${contentregion}&h=${btoa( - Date.now() - )}` - ).then((res) => res?.text()), + // Videos: use the wrapper that falls back after ~5s to inv_fallback + fetchVideoTextWithFallback(v, contentlang, contentregion), ]); const comments = this.getJson(invComments); @@ -276,7 +321,7 @@ const fetchWithRetry = async (url, options = {}, maxRetryTime = 8000) => { vid, comments, channel_uploads: " ", - engagement: returnyoutubedislikesapi.engagement, + engagement: returnyoutubedisapi.engagement, wiki: "", desc: "", color, @@ -308,7 +353,7 @@ const fetchWithRetry = async (url, options = {}, maxRetryTime = 8000) => { const pokeTubeApiCore = new InnerTubePokeVidious({ invapi: "https://invid-api.poketube.fun/bHj665PpYhUdPWuKPfZuQGoX/api/v1", - inv_fallback:"https://fallback-invid-api.poketube.fun/api/v1", + inv_fallback:"https://poketube.duti.dev/api/v1/videos/", useragent: config.useragent, });