Files
asepharyana a2542493cd fix(voice): screen share now carries audio — merge DASH video+audio into single NUT input
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.
2026-08-04 13:28:55 +07:00

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/,
);
});
});