From 71409df15fff216753dd05e45305d883bfa20209 Mon Sep 17 00:00:00 2001 From: ashley Date: Mon, 29 Sep 2025 07:26:24 +0200 Subject: [PATCH] fix stuff + add stuff --- src/libpoketube/libpoketube-youtube-player.js | 261 ++++++++++-------- 1 file changed, 145 insertions(+), 116 deletions(-) diff --git a/src/libpoketube/libpoketube-youtube-player.js b/src/libpoketube/libpoketube-youtube-player.js index afb2e806..01d3029d 100644 --- a/src/libpoketube/libpoketube-youtube-player.js +++ b/src/libpoketube/libpoketube-youtube-player.js @@ -65,57 +65,37 @@ class InnerTubePokeVidious { }; const fetchWithRetry = async (url, options = {}, maxRetryTime = 5000) => { - let lastError; - - const isTrigger = (s) => (s === 500 || s === 502); const RETRYABLE = new Set([429, 500, 502, 503, 504]); - const MIN_DELAY_MS = 150; - const BASE_DELAY_MS = 250; - const MAX_DELAY_MS = 2000; - const JITTER_FACTOR = 3; - const PER_TRY_TIMEOUT_MS = 2000; + const MIN_DELAY_MS = 100; + const BASE_DELAY_MS = 120; + const MAX_DELAY_MS = 800; + const JITTER_FACTOR = 2; + const PER_TRY_TIMEOUT_MS = 1500; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + 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 secs = Number(s); + if (Number.isFinite(secs)) return Math.max(0, (secs * 1000) | 0); const when = Date.parse(s); if (!Number.isNaN(when)) return Math.max(0, when - Date.now()); return null; }; - let res; - try { - res = await fetch(url, { - ...options, - headers: { - ...options?.headers, - ...headers, - }, - }); - } catch (err) { - this?.initError?.(`Fetch error for ${url}`, err); - throw err; - } - - if (res.ok) return res; - if (!isTrigger(res.status)) return res; - - const retryStart = Date.now(); - let delayMs = BASE_DELAY_MS; - let attempt = 1; const callerSignal = options?.signal || null; - const attemptWithTimeout = async (timeoutMs) => { + const attemptWithTimeout = async (timeoutMs, attemptSignal) => { 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) { + 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 (attemptSignal) { + if (attemptSignal.aborted) { + controller.abort(attemptSignal.reason || new Error("Aborted by attemptSignal")); + } else { + attemptSignal.addEventListener("abort", onCallerAbort, { once: true }); + } + } else if (callerSignal) { if (callerSignal.aborted) { controller.abort(callerSignal.reason || new Error("Aborted by caller")); } else { @@ -133,30 +113,38 @@ class InnerTubePokeVidious { }); } finally { clearTimeout(timer); - if (callerSignal) callerSignal.removeEventListener("abort", onCallerAbort); + if (attemptSignal) attemptSignal.removeEventListener("abort", onCallerAbort); + if (callerSignal && !attemptSignal) callerSignal.removeEventListener("abort", onCallerAbort); } }; + const start = Date.now(); + let delayMs = BASE_DELAY_MS; + let attempt = 1; + let lastErr = null; + while (true) { - const elapsed = Date.now() - retryStart; + const elapsed = Date.now() - start; const remaining = maxRetryTime - elapsed; if (remaining <= 0) { - throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms`); + 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 r = await attemptWithTimeout(perTryTimeout); - if (r.ok) return r; + const res = await attemptWithTimeout(perTryTimeout, callerSignal); + // if success or non-retryable status, return immediately + if (res.ok) return res; + if (!RETRYABLE.has(res.status)) return res; - if (!RETRYABLE.has(r.status)) { - return r; - } - - const retryAfterMs = parseRetryAfter(r.headers.get("Retry-After")); + // retryable status -> compute wait + const ra = parseRetryAfter(res.headers.get("Retry-After")); let waitMs; - if (retryAfterMs != null) { - waitMs = Math.max(MIN_DELAY_MS, Math.min(retryAfterMs, Math.max(0, remaining - 10))); + if (ra != null) { + waitMs = Math.max(MIN_DELAY_MS, Math.min(ra, Math.max(0, remaining - 10))); } else { const next = Math.min(MAX_DELAY_MS, Math.random() * delayMs * JITTER_FACTOR); delayMs = next < MIN_DELAY_MS ? MIN_DELAY_MS : next; @@ -167,20 +155,21 @@ class InnerTubePokeVidious { throw new Error(`Fetch failed for ${url} after ${maxRetryTime}ms (window depleted)`); } - this?.initError?.(`Retrying fetch for ${url}`, r.status); + this?.initError?.(`Retrying fetch for ${url}`, res.status); attempt++; await sleep(waitMs); continue; } catch (err) { + // aborted by caller -> bubble up if (callerSignal && callerSignal.aborted) throw err; - lastError = err; - const remaining2 = maxRetryTime - (Date.now() - retryStart); - if (remaining2 <= 0) throw lastError; + lastErr = err; + const remaining2 = maxRetryTime - (Date.now() - start); + if (remaining2 <= 0) throw lastErr; 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; + if (waitMs <= 0) throw lastErr; this?.initError?.(`Fetch error for ${url}`, err); attempt++; @@ -190,60 +179,82 @@ class InnerTubePokeVidious { } }; - // --- scheduling logic --- - // Rotate which window - // prefers fallback/primary every 2 hours using a custom sequence. - // - // Sequence: fallback, normal, normal, normal, normal, fallback - // each element = which API is preferred *for that 2-hour block*. - // - // Within the chosen preference, the 10-minute switching (minute % 20 >= 10) - // still applies, but which side corresponds to the inFallbackWindow is flipped - // depending on the 2-hour preference. - const minute = new Date().getMinutes(); const hour = new Date().getHours(); - // pattern for each 2-hour block (6 blocks to cover 12 hours; repeats every 12 hours) const pattern = ["fallback", "normal", "normal", "normal", "normal", "fallback"]; - - // determine which 2-hour slot we're in (0..11 hours cover repeating pattern every 12 hours) const twoHourIndex = Math.floor(hour / 2) % pattern.length; - const currentPreference = pattern[twoHourIndex]; // 'fallback' or 'normal' + const currentPreference = pattern[twoHourIndex]; + const inFallbackWindow = minute % 20 >= 10; - // 10-minute toggle windows - const inFallbackWindow = minute % 20 >= 10; - - // build the URLs 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())}`; - // decide which URL is chosen first based on 2-hour preference and 10-minute window - // If currentPreference === 'fallback', we bias the schedule so the inFallbackWindow - // maps to fallback being primary. If it's 'normal', we map inFallbackWindow to primary. const preferFallbackPrimary = currentPreference === "fallback"; - const chooseFirst = preferFallbackPrimary ? inFallbackWindow ? fallbackUrl : primaryUrl : inFallbackWindow ? primaryUrl : fallbackUrl; + const chooseFirst = preferFallbackPrimary ? (inFallbackWindow ? fallbackUrl : primaryUrl) : (inFallbackWindow ? primaryUrl : fallbackUrl); const chooseSecond = chooseFirst === primaryUrl ? fallbackUrl : primaryUrl; + // Helper: race two fetch attempts but prefer whichever returns ok first. + const fetchPrefer = async (urlA, urlB, maxRetryTime = 5000) => { + const acA = new AbortController(); + const acB = new AbortController(); + const wrapped = (url, ac) => + fetchWithRetry(url, { signal: ac.signal }, maxRetryTime) + .then((res) => ({ url, res })) + .catch((err) => ({ url, err })); + + // start both in parallel immediately to reduce latency + const pA = wrapped(urlA, acA); + const pB = wrapped(urlB, acB); + + // wait for any to return an ok response quickly + const results = await Promise.allSettled([pA, pB]); + + // Prefer an ok response from either promise + for (const r of results) { + if (r.status === "fulfilled" && r.value && r.value.res && r.value.res.ok) { + // abort the other one + if (r.value.url === urlA) acB.abort(); + else acA.abort(); + return r.value.res; + } + } + + // If none were ok, prefer the first fulfilled non-error response + for (const r of results) { + if (r.status === "fulfilled" && r.value && r.value.res) { + if (r.value.url === urlA) acB.abort(); + else acA.abort(); + return r.value.res; + } + } + + // otherwise throw the first error we have + for (const r of results) { + if (r.status === "fulfilled" && r.value && r.value.err) throw r.value.err; + } + // fallback: throw aggregated + throw new Error("Both fetches failed"); + }; + try { - const [invComments, videoInfo] = await Promise.all([ - fetchWithRetry( - `${this.config.invapi}/comments/${v}?hl=${contentlang}®ion=${contentregion}&h=${this.toBase64( - Date.now() - )}` - ).then((res) => res?.text()), - (async () => { - try { - const r = await fetchWithRetry(chooseFirst); - if (r.ok) return await r.text(); - throw new Error(`First API ${chooseFirst} failed with ${r.status}`); - } catch (err) { - this.initError("Primary choice failed, trying secondary", err); - const r2 = await fetchWithRetry(chooseSecond); - return await r2.text(); - } - })(), - ]); + 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; + }); + + 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); @@ -258,28 +269,45 @@ class InnerTubePokeVidious { } if (this.checkUnexistingObject(vid)) { - let returnyoutubedislikesapi = { engagement: null }; - try { - returnyoutubedislikesapi = await getdislikes(v); - } catch (err) { - this.initError("Dislike API error", err); - } + // Run dislikes and thumbnail color extraction in parallel (don't block) + const dislikePromise = (async () => { + try { + return await getdislikes(v); + } catch (err) { + this.initError("Dislike API error", err); + return { engagement: null }; + } + })(); + + const colorPromise = (async () => { + try { + // Fetch thumbnail colors but give it a short timeout by wrapping in Promise.race + // `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}`; + // get-image-colors may fetch internally; keep it but don't block main window too long + const p = getColors(imgUrl); + const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("Color extraction timeout")), 1200)); + 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"; - 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 palette = await getColors( - `https://i.ytimg.com/vi/${v}/hqdefault.jpg?sqp=${this.sqp}` - ); - if (Array.isArray(palette) && palette[0] && palette[1]) { - color = palette[0].hex(); - color2 = palette[1].hex(); - } - } catch (err) { - this.initError("Thumbnail color extraction error", err); + if (Array.isArray(paletteResult) && paletteResult[0]) { + color = paletteResult[0] || color; + color2 = paletteResult[1] || color2; } this.cache[v] = { @@ -287,7 +315,7 @@ class InnerTubePokeVidious { vid, comments, channel_uploads: " ", - engagement: returnyoutubedislikesapi.engagement, + engagement: returnyoutubedislikesapi?.engagement ?? null, wiki: "", desc: "", color, @@ -302,6 +330,7 @@ class InnerTubePokeVidious { } } catch (error) { this.initError(`Error getting video ${v}`, error); + return { error: true, message: "Fetch error", detail: String(error) }; } }