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:
asepharyana
2026-08-12 13:23:59 +07:00
parent 67ab289caa
commit 88b13225cd
3 changed files with 115 additions and 409 deletions
@@ -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;
} }
}); });
}); });