fix(gateway): stream screen-share input from yt-dlp stdout — no more raw-URL 403
Second root cause (2026-08-12): even with yt-dlp http_headers forwarded, YouTube still returns 403 when a signed DASH URL from --dump-single-json is fetched raw by ffmpeg/curl on some videos (verified on fONoh7Pc6VU: curl with the EXACT headers got 403; yt-dlp's own downloader succeeded). The signature is tied to the extracting client context (po_token/visitor), not just UA/IP. Fix: getDirectScreenInput now spawns 'yt-dlp -o -' and returns its stdout as a Readable — the same mechanism resolveMediaUrl already uses for music. yt-dlp handles auth, cookies and transient retries internally. Merge fragments go to /tmp/gmw-ytdlp-tmp (Nix store CWD is read-only → EACCES). Removed resolveScreenInput + mergeScreenStreams (dead code). Controller resolveInputWithRetry unchanged: tees the stream, waits for the first byte (12s), retries with a fresh yt-dlp run up to 3x on error/EOF/ timeout, and destroys stuck inputs (EPIPE) so no process leaks. Tests: rewritten for streaming (yt-dlp emits bytes; fail mode = exit 8 without stdout → stream must terminate with zero bytes).
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { chmodSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { PassThrough, type Readable } from "node:stream";
|
import { PassThrough, type Readable } from "node:stream";
|
||||||
import { StreamType } from "@discordjs/voice";
|
import { StreamType } from "@discordjs/voice";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
@@ -378,283 +381,97 @@ export function resolveMediaUrl(
|
|||||||
* Resolve a media URL to a single playable input stream for screen share /
|
* Resolve a media URL to a single playable input stream for screen share /
|
||||||
* GoLive streaming.
|
* GoLive streaming.
|
||||||
*
|
*
|
||||||
* yt-dlp `--get-url` with `bestvideo+bestaudio` prints the video-only and
|
* Streams the merged video+audio media directly from yt-dlp stdout (`-o -`).
|
||||||
* audio-only URLs on SEPARATE lines. The old code took only the first line
|
|
||||||
* (video-only) → ffmpeg had no audio track → GoLive stream had no sound.
|
|
||||||
*
|
*
|
||||||
* This returns a single input that `prepareStream` (which accepts only ONE
|
* This is deliberately NOT the old --dump-single-json + manual URL-fetch
|
||||||
* ffmpeg input) can consume while STILL including audio:
|
* approach: YouTube signs DASH URLs for the extracting client and rejects
|
||||||
* - If yt-dlp offers a merged progressive URL (one URL, video+audio) it is
|
* them with 403 when fetched raw by ffmpeg/curl (verified 2026-08-12: even
|
||||||
* returned directly.
|
* curl with the EXACT http_headers from the yt-dlp dump got 403 on some
|
||||||
* - Otherwise the video-only + audio-only DASH URLs are fetched in the SAME
|
* videos, while yt-dlp's own downloader succeeded). Streaming from yt-dlp
|
||||||
* yt-dlp run (signature URLs expire quickly) and merged locally by an
|
* lets it handle auth, cookies and transient retries internally — the same
|
||||||
* ffmpeg process into a single NUT stream, which is streamed to the
|
* mechanism resolveMediaUrl already uses for music playback.
|
||||||
* consumer over a Readable. NUT over stdin auto-probes cleanly (verified:
|
|
||||||
* av1+opus merge → H264+opus transcode).
|
|
||||||
*
|
*
|
||||||
* @returns a direct video URL (string) or a Readable of the merged NUT stream.
|
* @returns a Readable of the merged media stream.
|
||||||
*/
|
*/
|
||||||
export function getDirectScreenInput(url: string): Promise<string | Readable> {
|
export function getDirectScreenInput(url: string): Promise<Readable> {
|
||||||
return new Promise<string | Readable>((resolve, reject) => {
|
return new Promise<Readable>((resolve) => {
|
||||||
|
// Merge fragments must NOT be written to the process CWD — the Nix
|
||||||
|
// store dir is read-only for the deployed gateway (EACCES). Use a
|
||||||
|
// per-run temp dir (world-writable like /tmp) so parallel/retry runs
|
||||||
|
// never collide on merge fragments and any user can write to it.
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "gmw-ytdlp-"));
|
||||||
|
chmodSync(tmpDir, 0o1777);
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
url,
|
"-f",
|
||||||
"--dump-single-json",
|
|
||||||
"--format",
|
|
||||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||||
|
"-o",
|
||||||
|
"-",
|
||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
"--quiet",
|
"--no-progress",
|
||||||
// NOTE: deliberately NOT --no-simulate. Simulate mode still resolves the
|
"-P",
|
||||||
// requested format URLs into the JSON (requested_formats[].url), and it
|
tmpDir,
|
||||||
// avoids yt-dlp writing .part files into the process CWD — which is the
|
url,
|
||||||
// read-only Nix store dir for the deployed gateway (EACCES).
|
|
||||||
];
|
];
|
||||||
|
|
||||||
logger.info({ url }, "Spawning yt-dlp for screen share input resolution");
|
logger.info({ url }, "Spawning yt-dlp for screen share input streaming");
|
||||||
|
|
||||||
const proc = spawn("yt-dlp", args, {
|
const proc = spawn("yt-dlp", args, {
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
|
|
||||||
let stdoutBuf = "";
|
const stream = new PassThrough();
|
||||||
|
proc.stdout.pipe(stream);
|
||||||
|
|
||||||
let stderrBuf = "";
|
let stderrBuf = "";
|
||||||
const MAX_STDERR = 4096;
|
const MAX_STDERR = 4096;
|
||||||
const MAX_STDOUT = 8 * 1024 * 1024; // JSON metadata + requested format URLs
|
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
if (stderrBuf.length < MAX_STDERR) {
|
||||||
|
stderrBuf += chunk.toString("utf8");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (proc.stdout) {
|
let producedData = false;
|
||||||
proc.stdout.on("data", (chunk: Buffer) => {
|
stream.once("data", () => {
|
||||||
if (stdoutBuf.length < MAX_STDOUT) {
|
producedData = true;
|
||||||
stdoutBuf += chunk
|
});
|
||||||
.toString("utf8")
|
|
||||||
.slice(0, MAX_STDOUT - stdoutBuf.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (proc.stderr) {
|
|
||||||
proc.stderr.on("data", (chunk: Buffer) => {
|
|
||||||
if (stderrBuf.length < MAX_STDERR) {
|
|
||||||
stderrBuf += chunk
|
|
||||||
.toString("utf8")
|
|
||||||
.slice(0, MAX_STDERR - stderrBuf.length);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
proc.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
activeProcesses.delete(proc);
|
activeProcesses.delete(proc);
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
if (err.code === "ENOENT") {
|
if (err.code === "ENOENT") {
|
||||||
reject(buildNotInstalledError());
|
stream.destroy(buildNotInstalledError());
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`yt-dlp failed to start: ${err.message}`));
|
stream.destroy(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on("close", (code) => {
|
proc.on("close", (code) => {
|
||||||
activeProcesses.delete(proc);
|
activeProcesses.delete(proc);
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
if (code !== 0) {
|
// Fail fast: a download that dies before producing ANY bytes (e.g.
|
||||||
|
// transient YouTube 403) cannot feed the encoder — destroy the stream
|
||||||
|
// so the caller retries with a fresh yt-dlp run instead of streaming
|
||||||
|
// a silent black tile.
|
||||||
|
if (code !== 0 && !producedData && !stream.destroyed) {
|
||||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
||||||
reject(
|
stream.destroy(
|
||||||
new Error(
|
new Error(
|
||||||
`yt-dlp screen input resolution exited with code ${code}${detail}`,
|
`yt-dlp screen input stream failed (exit ${code})${detail}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
|
|
||||||
} catch (parseErr) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Failed to parse yt-dlp JSON for screen input: ${(parseErr as Error).message}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveScreenInput(parsed).then(resolve, (err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
reject(
|
|
||||||
new Error(`Failed to build screen input for "${url}": ${message}`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve immediately — data flows as yt-dlp downloads. The caller's
|
||||||
|
// resolveInputWithRetry validates the first byte and retries on failure.
|
||||||
|
resolve(stream);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* From a parsed yt-dlp JSON info dict, decide how to feed a single ffmpeg
|
|
||||||
* input with both video and audio.
|
|
||||||
*/
|
|
||||||
async function resolveScreenInput(
|
|
||||||
info: Record<string, unknown>,
|
|
||||||
): Promise<string | Readable> {
|
|
||||||
const requested = info.requested_formats as
|
|
||||||
| Array<Record<string, unknown>>
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// Merged/progressive single URL (video+audio in one). Common when yt-dlp
|
|
||||||
// selects a single format (e.g. format 18 progressive mp4) or when a direct
|
|
||||||
// muxed URL is available.
|
|
||||||
const singleUrl = info.url as string | undefined;
|
|
||||||
const singleHasAudio =
|
|
||||||
info.acodec !== "none" &&
|
|
||||||
typeof info.acodec === "string" &&
|
|
||||||
info.acodec.length > 0;
|
|
||||||
|
|
||||||
if (typeof singleUrl === "string" && singleUrl && singleHasAudio) {
|
|
||||||
logger.debug("Screen share uses merged progressive single URL");
|
|
||||||
return singleUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate video-only + audio-only DASH formats → merge locally via ffmpeg.
|
|
||||||
if (Array.isArray(requested) && requested.length >= 2) {
|
|
||||||
const video = requested.find(
|
|
||||||
(rf) => rf.vcodec && String(rf.vcodec) !== "none",
|
|
||||||
);
|
|
||||||
const audio = requested.find(
|
|
||||||
(rf) => rf.acodec && String(rf.acodec) !== "none",
|
|
||||||
);
|
|
||||||
const videoUrl = video?.url as string | undefined;
|
|
||||||
const audioUrl = audio?.url as string | undefined;
|
|
||||||
// yt-dlp returns the exact HTTP headers needed to fetch each signed DASH
|
|
||||||
// URL (User-Agent etc.). Passing them to the merge ffmpeg prevents
|
|
||||||
// transient YouTube 403s ("Server returned 403 Forbidden") that kill the
|
|
||||||
// stream before it starts.
|
|
||||||
const videoHeaders =
|
|
||||||
(video?.http_headers as Record<string, string> | undefined) ?? {};
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof videoUrl === "string" &&
|
|
||||||
videoUrl.length > 0 &&
|
|
||||||
typeof audioUrl === "string" &&
|
|
||||||
audioUrl.length > 0
|
|
||||||
) {
|
|
||||||
return mergeScreenStreams(videoUrl, audioUrl, videoHeaders);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"yt-dlp returned neither a merged progressive URL nor a video+audio format pair",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge a video-only URL and an audio-only URL into a single NUT stream using
|
|
||||||
* a child ffmpeg process. Both URLs come from the same yt-dlp run, so they
|
|
||||||
* share the same signature/expiry and are consumed immediately.
|
|
||||||
*
|
|
||||||
* Fail-fast contract: if the merge process exits non-zero BEFORE producing any
|
|
||||||
* output bytes (e.g. transient YouTube 403), the returned Readable is
|
|
||||||
* destroyed with an error so the caller can retry — otherwise the screen
|
|
||||||
* share would "start" with a dead input and stream a black tile forever.
|
|
||||||
*/
|
|
||||||
function mergeScreenStreams(
|
|
||||||
videoUrl: string,
|
|
||||||
audioUrl: string,
|
|
||||||
httpHeaders?: Record<string, string>,
|
|
||||||
): Readable {
|
|
||||||
logger.info("Merging video+audio DASH streams into a single NUT input");
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-reconnect",
|
|
||||||
"1",
|
|
||||||
"-reconnect_streamed",
|
|
||||||
"1",
|
|
||||||
"-reconnect_delay_max",
|
|
||||||
"5",
|
|
||||||
];
|
|
||||||
// Pass the browser-like headers yt-dlp attached to the signed URLs. Without
|
|
||||||
// a proper User-Agent YouTube sometimes answers 403 to ffmpeg's plain
|
|
||||||
// Lavf/… agent and the whole stream dies before producing a frame.
|
|
||||||
const headerStr = Object.entries(httpHeaders ?? {})
|
|
||||||
.map(([k, v]) => `${k}: ${v}`)
|
|
||||||
.join("\r\n");
|
|
||||||
if (headerStr) {
|
|
||||||
args.push("-headers", headerStr);
|
|
||||||
}
|
|
||||||
args.push(
|
|
||||||
"-i",
|
|
||||||
videoUrl,
|
|
||||||
"-i",
|
|
||||||
audioUrl,
|
|
||||||
"-map",
|
|
||||||
"0:v:0",
|
|
||||||
"-map",
|
|
||||||
"1:a:0",
|
|
||||||
"-c:v",
|
|
||||||
"copy",
|
|
||||||
"-c:a",
|
|
||||||
"copy",
|
|
||||||
"-f",
|
|
||||||
"nut",
|
|
||||||
"pipe:1",
|
|
||||||
);
|
|
||||||
|
|
||||||
const ffmpeg = spawn("ffmpeg", args, {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track so cleanup() can terminate the merge during graceful shutdown.
|
|
||||||
activeProcesses.add(ffmpeg);
|
|
||||||
ffmpeg.once("exit", () => {
|
|
||||||
activeProcesses.delete(ffmpeg);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent the ffmpeg stderr from filling the pipe buffer / leaking.
|
|
||||||
let stderrBuf = "";
|
|
||||||
const MAX_STDERR = 4096;
|
|
||||||
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
|
|
||||||
if (stderrBuf.length < MAX_STDERR) {
|
|
||||||
stderrBuf += chunk.toString("utf8");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let producedData = false;
|
|
||||||
|
|
||||||
ffmpeg.on("error", (err) => {
|
|
||||||
const msg =
|
|
||||||
err.message === "spawn ffmpeg ENOENT"
|
|
||||||
? "FFmpeg not found! Install ffmpeg in the container."
|
|
||||||
: err.message;
|
|
||||||
logger.error({ error: msg }, "Screen stream merge ffmpeg error");
|
|
||||||
stream.destroy(new Error(msg));
|
|
||||||
});
|
|
||||||
|
|
||||||
ffmpeg.on("exit", (code) => {
|
|
||||||
const stderr = stderrBuf.trim();
|
|
||||||
logger.warn(
|
|
||||||
{ code, stderr: stderr.slice(-500) || undefined },
|
|
||||||
"Screen stream merge ffmpeg exited",
|
|
||||||
);
|
|
||||||
// Fail fast: a merge that dies before emitting ANY bytes cannot feed the
|
|
||||||
// encoder — destroy the stream with an error so the caller retries with a
|
|
||||||
// fresh resolution instead of streaming a silent black tile.
|
|
||||||
if (code !== 0 && !producedData && !stream.destroyed) {
|
|
||||||
stream.destroy(
|
|
||||||
new Error(
|
|
||||||
`Screen stream merge failed before producing data (exit ${code})${stderr ? `: ${stderr.slice(-300)}` : ""}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = ffmpeg.stdout;
|
|
||||||
stream.setMaxListeners(32);
|
|
||||||
stream.once("data", () => {
|
|
||||||
producedData = true;
|
|
||||||
});
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract metadata (title, duration, thumbnail) from a media URL
|
* Extract metadata (title, duration, thumbnail) from a media URL
|
||||||
* without downloading the audio stream.
|
* without downloading the audio stream.
|
||||||
|
|||||||
@@ -66,20 +66,13 @@ export class ScreenShareController {
|
|||||||
* retried with a FRESH yt-dlp run (signed DASH URLs expire quickly — the
|
* retried with a FRESH yt-dlp run (signed DASH URLs expire quickly — the
|
||||||
* old URLs cannot simply be re-fetched).
|
* old URLs cannot simply be re-fetched).
|
||||||
*/
|
*/
|
||||||
private async resolveInputWithRetry(
|
private async resolveInputWithRetry(source: string): Promise<Readable> {
|
||||||
source: string,
|
|
||||||
): Promise<string | Readable> {
|
|
||||||
const MAX_ATTEMPTS = 3;
|
const MAX_ATTEMPTS = 3;
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
try {
|
try {
|
||||||
const input = await getDirectScreenInput(source);
|
const input = await getDirectScreenInput(source);
|
||||||
if (typeof input === "string") {
|
|
||||||
// Direct URL input — nothing to validate; the encoder ffmpeg will
|
|
||||||
// connect itself and fail loudly on a bad URL.
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tee = new PassThrough();
|
const tee = new PassThrough();
|
||||||
input.on("error", (err) => tee.destroy(err));
|
input.on("error", (err) => tee.destroy(err));
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// Screen share input resolution tests
|
// Screen share input resolution tests
|
||||||
//
|
//
|
||||||
// Verifies the decision logic of getDirectScreenInput:
|
// getDirectScreenInput now streams the merged video+audio media straight from
|
||||||
// - merged progressive URL → returned directly
|
// yt-dlp stdout (`-o -`) — same auth-handling mechanism as resolveMediaUrl for
|
||||||
// - video+audio DASH pair → local ffmpeg merge (Readable)
|
// music. There is no manual URL fetch or local ffmpeg merge anymore.
|
||||||
// - neither → rejection
|
|
||||||
//
|
//
|
||||||
// Both yt-dlp and ffmpeg are faked via PATH shim scripts so the test does not
|
// yt-dlp is faked via a PATH shim script so the test does not hit the network
|
||||||
// hit the network or need real binaries.
|
// or need real binaries.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -31,39 +30,21 @@ const realPath = process.env.PATH;
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
||||||
|
|
||||||
// Fake yt-dlp: prints the JSON file named in GMW_FAKE_YTDLP_JSON.
|
// Fake yt-dlp: streams a few bytes to stdout (like `yt-dlp -o -` does).
|
||||||
// If the file is missing → exits 1 (mimics yt-dlp failure).
|
|
||||||
const ytShim = `#!/usr/bin/env bash
|
|
||||||
if [ -n "$GMW_FAKE_YTDLP_JSON" ] && [ -f "$GMW_FAKE_YTDLP_JSON" ]; then
|
|
||||||
cat "$GMW_FAKE_YTDLP_JSON"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "yt-dlp: fake JSON missing" >&2
|
|
||||||
exit 1
|
|
||||||
`;
|
|
||||||
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
|
||||||
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
|
||||||
|
|
||||||
// Fake ffmpeg: writes a small nut-ish payload to stdout so the returned
|
|
||||||
// Readable actually emits data (the merge path in mergeScreenStreams).
|
|
||||||
// Modes (env):
|
// Modes (env):
|
||||||
// GMW_FAKE_FFMPEG_FAIL=1 → exit 1, no stdout (mimics transient 403)
|
// GMW_FAKE_YTDLP_FAIL=1 → stderr 403 + exit 8 WITHOUT stdout bytes
|
||||||
// GMW_FAKE_FFMPEG_DUMP_ARGS=<file> → append argv to the file (asserts
|
// (mimics a download rejected by YouTube).
|
||||||
// flags like -headers are forwarded to the merge process)
|
const ytShim = `#!/usr/bin/env bash
|
||||||
const ffShim = `#!/usr/bin/env bash
|
if [ "$GMW_FAKE_YTDLP_FAIL" = "1" ]; then
|
||||||
if [ -n "$GMW_FAKE_FFMPEG_DUMP_ARGS" ]; then
|
echo "ERROR: [youtube] ...: 403 Forbidden (access denied)" >&2
|
||||||
printf '%s\\n' "$*" >> "$GMW_FAKE_FFMPEG_DUMP_ARGS"
|
|
||||||
fi
|
|
||||||
if [ "$GMW_FAKE_FFMPEG_FAIL" = "1" ]; then
|
|
||||||
echo "403 Forbidden" >&2
|
|
||||||
exit 8
|
exit 8
|
||||||
fi
|
fi
|
||||||
# Fake ffmpeg — ignore args, emit a few bytes so consumers see a live stream.
|
# Fake yt-dlp — ignore args, emit a few bytes so consumers see a live stream.
|
||||||
head -c 4096 /dev/urandom
|
head -c 4096 /dev/urandom
|
||||||
exit 0
|
exit 0
|
||||||
`;
|
`;
|
||||||
writeFileSync(join(fakeBinDir, "ffmpeg"), ffShim);
|
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
||||||
chmodSync(join(fakeBinDir, "ffmpeg"), 0o755);
|
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
||||||
|
|
||||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
||||||
});
|
});
|
||||||
@@ -76,161 +57,76 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||||
function writeFakeJson(payload: Record<string, unknown>): string {
|
function consumeStream(stream: Readable): Promise<string> {
|
||||||
const p = join(
|
return new Promise<string>((resolve) => {
|
||||||
tmpdir(),
|
let got = 0;
|
||||||
`gmw-fake-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
stream.on("data", (chunk: Buffer) => {
|
||||||
);
|
got += chunk.length;
|
||||||
writeFileSync(p, JSON.stringify(payload));
|
});
|
||||||
return p;
|
stream.on("error", () => resolve(`error-after-${got}B`));
|
||||||
}
|
stream.on("end", () => resolve(`end-after-${got}B`));
|
||||||
|
stream.resume();
|
||||||
function dashPairInfo(videoUrl: string, audioUrl: string) {
|
});
|
||||||
return {
|
|
||||||
url: null,
|
|
||||||
acodec: "none", // top-level is not a single merged format
|
|
||||||
vcodec: "av01",
|
|
||||||
requested_formats: [
|
|
||||||
{
|
|
||||||
format_id: "136",
|
|
||||||
vcodec: "avc1.4d401f",
|
|
||||||
acodec: "none",
|
|
||||||
url: videoUrl,
|
|
||||||
},
|
|
||||||
{ format_id: "140", vcodec: "none", acodec: "mp4a.40.2", url: audioUrl },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── tests ─────────────────────────────────────────────────────────────────────
|
// ─── tests ─────────────────────────────────────────────────────────────────────
|
||||||
describe("getDirectScreenInput", () => {
|
describe("getDirectScreenInput", () => {
|
||||||
it("returns the single merged progressive URL when the info has one", async () => {
|
it("returns a live Readable and streams media bytes from yt-dlp stdout", async () => {
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
|
||||||
url: "https://cdn.example/progressive.mp4",
|
|
||||||
acodec: "mp4a.40.2",
|
|
||||||
vcodec: "avc1",
|
|
||||||
});
|
|
||||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
|
||||||
expect(result).toBe("https://cdn.example/progressive.mp4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a live Readable when a video+audio DASH pair must be merged", async () => {
|
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(
|
|
||||||
dashPairInfo(
|
|
||||||
"https://cdn.example/video.mp4",
|
|
||||||
"https://cdn.example/audio.m4a",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||||
expect(Readable.isReadable(result)).toBe(true);
|
expect(Readable.isReadable(result)).toBe(true);
|
||||||
|
|
||||||
// The fake ffmpeg emits bytes; collect a chunk to prove the stream flows.
|
const outcome = await consumeStream(result);
|
||||||
const bytes = await new Promise<number>((resolve, reject) => {
|
// The fake yt-dlp emits 4096 bytes → the stream must deliver them.
|
||||||
const stream = result as Readable;
|
expect(outcome).toMatch(/^(error|end)-after-[1-9]\d*B$/);
|
||||||
let got = 0;
|
|
||||||
stream.on("data", (chunk: Buffer) => {
|
|
||||||
got += chunk.length;
|
|
||||||
});
|
|
||||||
stream.on("error", reject);
|
|
||||||
stream.on("end", () => resolve(got));
|
|
||||||
stream.resume();
|
|
||||||
});
|
|
||||||
expect(bytes).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when yt-dlp returns neither a merged URL nor a format pair", async () => {
|
it("destroys the stream with an error when yt-dlp fails before producing data (transient 403)", async () => {
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
// Simulate the production failure: yt-dlp's downloader hits a transient
|
||||||
url: null,
|
// YouTube 403 and exits non-zero WITHOUT emitting a single byte. The
|
||||||
acodec: "none",
|
// returned Readable must terminate with zero bytes (error OR end) so the
|
||||||
vcodec: "none",
|
// controller's resolveInputWithRetry retries with a fresh run instead of
|
||||||
requested_formats: [],
|
// streaming a silent black tile.
|
||||||
});
|
process.env.GMW_FAKE_YTDLP_FAIL = "1";
|
||||||
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
|
||||||
/neither a merged progressive URL nor a video\+audio/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when yt-dlp exits non-zero", async () => {
|
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = "/nonexistent/gmw-fake.json";
|
|
||||||
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
|
||||||
/screen input resolution exited with code 1/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("terminates with ZERO bytes when the merge ffmpeg fails before producing data (transient 403)", async () => {
|
|
||||||
// Simulate the 11:50 production failure: yt-dlp resolves fine, but the
|
|
||||||
// merge ffmpeg hits a transient YouTube 403 and exits non-zero WITHOUT
|
|
||||||
// emitting a single byte. getDirectScreenInput still resolves (the
|
|
||||||
// Readable exists) — the fail-fast contract: the stream must terminate
|
|
||||||
// (error OR end — the end-before-exit ordering makes both possible)
|
|
||||||
// without ever delivering a frame to a consumer. The controller's
|
|
||||||
// resolveInputWithRetry turns either signal into a fresh retry.
|
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(
|
|
||||||
dashPairInfo(
|
|
||||||
"https://cdn.example/video.mp4",
|
|
||||||
"https://cdn.example/audio.m4a",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
process.env.GMW_FAKE_FFMPEG_FAIL = "1";
|
|
||||||
try {
|
try {
|
||||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||||
expect(Readable.isReadable(result)).toBe(true);
|
expect(Readable.isReadable(result)).toBe(true);
|
||||||
|
|
||||||
const outcome = await new Promise<string>((resolve) => {
|
const outcome = await consumeStream(result);
|
||||||
const stream = result as Readable;
|
|
||||||
let got = 0;
|
|
||||||
stream.on("data", (chunk: Buffer) => {
|
|
||||||
got += chunk.length;
|
|
||||||
});
|
|
||||||
stream.on("error", () => resolve(`error-after-${got}B`));
|
|
||||||
stream.on("end", () => resolve(`end-after-${got}B`));
|
|
||||||
stream.resume();
|
|
||||||
});
|
|
||||||
// Fail-fast: the consumer must NOT receive any bytes (no black-tile
|
|
||||||
// zombie stream). Either a destroyed-with-error stream or a clean
|
|
||||||
// end-before-exit is a valid terminal state — the caller retries.
|
|
||||||
expect(outcome).toMatch(/^(error|end)-after-0B$/);
|
expect(outcome).toMatch(/^(error|end)-after-0B$/);
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.GMW_FAKE_FFMPEG_FAIL;
|
delete process.env.GMW_FAKE_YTDLP_FAIL;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards yt-dlp http_headers to the merge ffmpeg (-headers)", async () => {
|
it("passes -o - (stdout streaming) and a temp dir to yt-dlp", async () => {
|
||||||
const info = dashPairInfo(
|
|
||||||
"https://cdn.example/video.mp4",
|
|
||||||
"https://cdn.example/audio.m4a",
|
|
||||||
);
|
|
||||||
// Add the browser-like headers yt-dlp attaches to signed DASH URLs.
|
|
||||||
(info.requested_formats[0] as Record<string, unknown>).http_headers = {
|
|
||||||
"User-Agent":
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
||||||
Referer: "https://www.youtube.com/",
|
|
||||||
};
|
|
||||||
const argsDump = join(
|
const argsDump = join(
|
||||||
tmpdir(),
|
tmpdir(),
|
||||||
`gmw-ffargs-${process.pid}-${Date.now()}.txt`,
|
`gmw-ytargs-${process.pid}-${Date.now()}.txt`,
|
||||||
);
|
);
|
||||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(info);
|
process.env.GMW_FAKE_YTDLP_DUMP_ARGS = argsDump;
|
||||||
process.env.GMW_FAKE_FFMPEG_DUMP_ARGS = argsDump;
|
// Augment the fake to dump its argv.
|
||||||
|
const shim = `#!/usr/bin/env bash
|
||||||
|
printf '%s\\n' "$*" >> "$GMW_FAKE_YTDLP_DUMP_ARGS"
|
||||||
|
head -c 4096 /dev/urandom
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
const realPath2 = process.env.PATH;
|
||||||
|
const dir = fakeBinDir as unknown as string;
|
||||||
|
const existing = join(dir, "yt-dlp");
|
||||||
|
// Overwrite with the argv-dumping variant.
|
||||||
|
writeFileSync(existing, shim);
|
||||||
|
chmodSync(existing, 0o755);
|
||||||
try {
|
try {
|
||||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||||
// Consume the stream so the merge ffmpeg process runs to completion.
|
await consumeStream(result);
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const stream = result as Readable;
|
|
||||||
stream.on("data", () => {});
|
|
||||||
stream.on("error", () => resolve());
|
|
||||||
stream.on("end", () => resolve());
|
|
||||||
stream.resume();
|
|
||||||
});
|
|
||||||
// Allow the fake ffmpeg to flush its argv dump.
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
const args = readFileSync(argsDump, "utf8").trim();
|
const args = readFileSync(argsDump, "utf8").trim();
|
||||||
expect(args).toContain("-headers");
|
expect(args).toContain("-o -");
|
||||||
expect(args).toContain("Mozilla/5.0");
|
expect(args).toMatch(/gmw-ytdlp-/);
|
||||||
expect(args).toContain("Referer: https://www.youtube.com/");
|
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.GMW_FAKE_FFMPEG_DUMP_ARGS;
|
delete process.env.GMW_FAKE_YTDLP_DUMP_ARGS;
|
||||||
rmSync(argsDump, { force: true });
|
rmSync(argsDump, { force: true });
|
||||||
|
process.env.PATH = realPath2;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user