getDirectVideoUrl used yt-dlp --get-url with bestvideo+bestaudio, which prints the video-only and audio-only URLs on SEPARATE lines. Only the first (video-only) line was used, so ffmpeg had no audio track and the GoLive stream had no sound. Replace with getDirectScreenInput which: - uses --dump-single-json to fetch both fresh URLs in ONE yt-dlp run (signature URLs expire quickly) - returns the merged progressive URL directly when one exists - otherwise merges the video-only + audio-only DASH URLs locally via a child ffmpeg into a single NUT stream consumed as a Readable - tracks the merge ffmpeg process in cleanup() so shutdown kills it too Verified end-to-end with real YouTube URLs: yt-dlp pair → live ffmpeg merge (NUT) → H264+opus transcode yields both streams. Added tests/screenShareInput.test.ts covering URL / DASH-pair / error paths.
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// Screen share input resolution tests
|
|
//
|
|
// Verifies the decision logic of getDirectScreenInput:
|
|
// - merged progressive URL → returned directly
|
|
// - video+audio DASH pair → local ffmpeg merge (Readable)
|
|
// - neither → rejection
|
|
//
|
|
// Both yt-dlp and ffmpeg are faked via PATH shim scripts so the test does not
|
|
// hit the network or need real binaries.
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { Readable } from "node:stream";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { getDirectScreenInput } from "../src/modules/voice-recording/mediaSource.js";
|
|
|
|
// ─── fake bin dir ──────────────────────────────────────────────────────────────
|
|
let fakeBinDir: string | null = null;
|
|
const realPath = process.env.PATH;
|
|
|
|
beforeAll(() => {
|
|
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
|
|
|
// Fake yt-dlp: prints the JSON file named in GMW_FAKE_YTDLP_JSON.
|
|
// 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).
|
|
const ffShim = `#!/usr/bin/env bash
|
|
# Fake ffmpeg — ignore args, emit a few bytes so consumers see a live stream.
|
|
head -c 4096 /dev/urandom
|
|
exit 0
|
|
`;
|
|
writeFileSync(join(fakeBinDir, "ffmpeg"), ffShim);
|
|
chmodSync(join(fakeBinDir, "ffmpeg"), 0o755);
|
|
|
|
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (fakeBinDir) {
|
|
rmSync(fakeBinDir, { recursive: true, force: true });
|
|
}
|
|
process.env.PATH = realPath;
|
|
});
|
|
|
|
// ─── helpers ───────────────────────────────────────────────────────────────────
|
|
function writeFakeJson(payload: Record<string, unknown>): string {
|
|
const p = join(
|
|
tmpdir(),
|
|
`gmw-fake-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
);
|
|
writeFileSync(p, JSON.stringify(payload));
|
|
return p;
|
|
}
|
|
|
|
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 ─────────────────────────────────────────────────────────────────────
|
|
describe("getDirectScreenInput", () => {
|
|
it("returns the single merged progressive URL when the info has one", 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");
|
|
expect(Readable.isReadable(result)).toBe(true);
|
|
|
|
// The fake ffmpeg emits bytes; collect a chunk to prove the stream flows.
|
|
const bytes = await new Promise<number>((resolve, reject) => {
|
|
const stream = result as Readable;
|
|
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 () => {
|
|
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
|
url: null,
|
|
acodec: "none",
|
|
vcodec: "none",
|
|
requested_formats: [],
|
|
});
|
|
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/,
|
|
);
|
|
});
|
|
});
|