feat(gateway): Invidious fallback for YouTube 403 in screen share

YouTube blocks anon + cookies terbind ke IP browser (403 download).
Auto-rewrite youtube.com -> yewtu.be/invidious mirror saat cookies gagal.
- mediaSource: export isYoutubeWatchUrl/toInvidiousUrl/INVIDIOUS_INSTANCES
- screenShareController: resolveInputWithRetry tries Invidious instances on 403
This commit is contained in:
asepharyana
2026-08-12 18:19:04 +07:00
parent 7a7f433988
commit bd292fdf3d
2 changed files with 89 additions and 6 deletions
@@ -1,5 +1,11 @@
import { type ChildProcess, spawn } from "node:child_process"; import { type ChildProcess, spawn } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import {
chmodSync,
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { PassThrough, type Readable } from "node:stream"; import { PassThrough, type Readable } from "node:stream";
@@ -239,9 +245,16 @@ function buildCookieArgs(): string[] {
if (envCookies && envCookies.includes("LOGIN_INFO")) { if (envCookies && envCookies.includes("LOGIN_INFO")) {
const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`); const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`);
writeFileSync(fdPath, envCookies); writeFileSync(fdPath, envCookies);
try { chmodSync(fdPath, 0o600); } catch { /* best-effort */ } try {
chmodSync(fdPath, 0o600);
} catch {
/* best-effort */
}
_cachedCookiePath = fdPath; _cachedCookiePath = fdPath;
logger.info({ cookiePath: fdPath, source: "GMW_YT_DOWNLOADER_COOKIES env" }, "Using YouTube cookies (from BWS env)"); logger.info(
{ cookiePath: fdPath, source: "GMW_YT_DOWNLOADER_COOKIES env" },
"Using YouTube cookies (from BWS env)",
);
return ["--cookies", fdPath]; return ["--cookies", fdPath];
} }
const cookiePath = const cookiePath =
@@ -249,16 +262,59 @@ function buildCookieArgs(): string[] {
try { try {
if (cookiePath && existsSync(cookiePath)) { if (cookiePath && existsSync(cookiePath)) {
_cachedCookiePath = cookiePath; _cachedCookiePath = cookiePath;
logger.info({ cookiePath, source: "on-disk file" }, "Using YouTube cookies for yt-dlp"); logger.info(
{ cookiePath, source: "on-disk file" },
"Using YouTube cookies for yt-dlp",
);
return ["--cookies", cookiePath]; return ["--cookies", cookiePath];
} }
} catch { } catch {
/* ignore — fallback to anon */ /* ignore — fallback to anon */
} }
logger.warn("No YouTube cookies available; yt-dlp will use anonymous (YouTube may 403)"); logger.warn(
"No YouTube cookies available; yt-dlp will use anonymous (YouTube may 403)",
);
return []; return [];
} }
/** Invidious instances for anon YouTube fetch (fallback when cookies 403). */
export const INVIDIOUS_INSTANCES = [
"yewtu.be",
"yewtu.nanomorph.dev",
"invidious.snopyta.org",
"invidious.kavin.rocks",
];
/** True if url is a YouTube watch URL (youtu.be / youtube.com/watch). */
export function isYoutubeWatchUrl(url: string): boolean {
try {
const u = new URL(url);
return (
u.hostname === "youtu.be" ||
(u.hostname === "www.youtube.com" && u.pathname === "/watch") ||
(u.hostname === "youtube.com" && u.pathname === "/watch")
);
} catch {
return false;
}
}
/** Rewrite a YouTube watch URL to an invidious instance (anon, no bot-check). */
export function toInvidiousUrl(url: string, instance: string): string {
try {
const u = new URL(url);
if (u.hostname === "youtu.be") {
const id = u.pathname.slice(1);
return `https://${instance}/watch?v=${id}`;
}
const id = u.searchParams.get("v");
if (id) return `https://${instance}/watch?v=${id}`;
return url;
} catch {
return url;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public API // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -8,7 +8,12 @@ import {
prepareStream, prepareStream,
Streamer, Streamer,
} from "../../goLive/index.js"; } from "../../goLive/index.js";
import { getDirectScreenInput } from "./mediaSource.js"; import {
getDirectScreenInput,
INVIDIOUS_INSTANCES,
isYoutubeWatchUrl,
toInvidiousUrl,
} from "./mediaSource.js";
import type { ScreenSharePlayback } from "./mediaTypes.js"; import type { ScreenSharePlayback } from "./mediaTypes.js";
import { discordPlayer } from "./player.js"; import { discordPlayer } from "./player.js";
@@ -70,7 +75,29 @@ export class ScreenShareController {
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
let lastError: Error | null = null; let lastError: Error | null = null;
// YouTube may 403 even with account cookies (IP-bound session / bot check
// on VPS IP). When the source is a YouTube URL and cookies fail, fall back
// to anon Invidious mirror instances — no auth needed.
const isYt = isYoutubeWatchUrl(source);
let invidiousIdx = 0;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
// On a 403 against YouTube, try the next Invidious instance for this attempt.
if (
isYt &&
lastError &&
/403|bot|Sign in|not a bot|access denied/i.test(lastError.message) &&
invidiousIdx < INVIDIOUS_INSTANCES.length
) {
const inst = INVIDIOUS_INSTANCES[invidiousIdx];
this.logger.warn(
{ attempt, instance: inst, error: lastError.message },
"YouTube blocked (403); falling back to Invidious mirror",
);
source = toInvidiousUrl(source, inst);
invidiousIdx++;
}
try { try {
const input = await getDirectScreenInput(source); const input = await getDirectScreenInput(source);