Files
GMW/services/discord-gateway/tests/mediaResolve.test.ts
T
asepharyana aa440eda69 fix(gateway): music/screen playback heads — read yt-dlp headers from stderr, drop no-simulate
Music playback produced no audio: with `-o -` yt-dlp streams media on
stdout and emits its `--print` title/duration headers on stderr, but
resolveMediaUrl read them from stdout — stripping two binary 'lines' off
the WebM container and corrupting the stream (player 'playing' but silent).
Now headers are read from stderr and the stdout media stream is returned
untouched.

Screenshare was failing with EACCES: getDirectScreenInput used --no-simulate,
making yt-dlp write .f*.part files into the read-only Nix store CWD. Dropped
it — simulate mode still returns requested_formats[].url in the JSON.

Adds tests/mediaResolve.test.ts (stderr-header + untouched-stream regression).
2026-08-06 21:26:50 +07:00

134 lines
5.6 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════════
// resolveMediaUrl (music playback) resolution tests
//
// Guards the yt-dlp stdout-vs-stderr contract that broke music playback:
// - with `-o -` yt-dlp streams media on STDOUT and emits `--print`
// title/duration headers on STDERR
// - resolveMediaUrl must read headers from stderr and return the stdout
// media stream UNTOUCHED (the old code stripped binary "lines" from the
// WebM container, corrupting it → silent playback)
//
// yt-dlp is faked via a PATH shim; no network or real binaries needed.
// ═══════════════════════════════════════════════════════════════════════════════
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveMediaUrl } from "../src/modules/voice-recording/mediaSource.js";
// ─── fake bin dir ──────────────────────────────────────────────────────────────
let fakeBinDir: string | null = null;
const realPath = process.env.PATH;
/** WebM-ish payload: EBML magic + a few bytes that must survive untouched. */
const MEDIA_PAYLOAD = Buffer.concat([
Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f]), // EBML magic
Buffer.from("fake-webm-cluster-data\nwith-newlines\n", "utf8"),
Buffer.from([0x00, 0x11, 0x22, 0x33, 0x0a, 0xff, 0xee]),
]);
beforeAll(() => {
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-media-resolve-"));
// Fake yt-dlp: reads GMW_FAKE_STDERR (what to write on stderr) and
// GMW_FAKE_STDOUT (what to write on stdout, default = MEDIA_PAYLOAD).
// Written with string concat so bash `${...}` isn't TS-interpolated.
const ytShim =
"#!/usr/bin/env bash\n" +
'if [ -f "$GMW_FAKE_STDERR" ]; then cat "$GMW_FAKE_STDERR" >&2; fi\n' +
'if [ -f "$GMW_FAKE_STDOUT" ]; then cat "$GMW_FAKE_STDOUT"; fi\n' +
"exit " +
(process.env.GMW_FAKE_EXIT ?? "0") +
"\n";
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
});
afterAll(() => {
if (fakeBinDir) {
rmSync(fakeBinDir, { recursive: true, force: true });
}
process.env.PATH = realPath;
});
// ─── helpers ───────────────────────────────────────────────────────────────────
function writeTemp(name: string, content: Buffer | string): string {
const p = join(
tmpdir(),
`gmw-resolve-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`,
);
writeFileSync(p, content);
return p;
}
function collect(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (c: Buffer) => chunks.push(c));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
// ─── tests ─────────────────────────────────────────────────────────────────────
describe("resolveMediaUrl", () => {
it("reads title+duration from STDERR and returns the untouched stdout media", async () => {
const stderrPath = writeTemp("stderr", "【Test】Song Title\n184.5\n");
const stdoutPath = writeTemp("stdout", MEDIA_PAYLOAD);
process.env.GMW_FAKE_STDERR = stderrPath;
process.env.GMW_FAKE_STDOUT = stdoutPath;
process.env.GMW_FAKE_EXIT = "0";
const resolution = await resolveMediaUrl("https://youtu.be/abc");
expect(resolution.title).toBe("【Test】Song Title");
expect(resolution.duration).toBeCloseTo(184.5, 1);
const bytes = await collect(resolution.stream);
expect(bytes).toEqual(MEDIA_PAYLOAD); // byte-for-byte intact
});
it("skips [info] prefix lines before the title header", async () => {
const stderrPath = writeTemp(
"stderr",
"[info] Downloading webpage\nTitle After Info\n123\n",
);
const stdoutPath = writeTemp("stdout", MEDIA_PAYLOAD);
process.env.GMW_FAKE_STDERR = stderrPath;
process.env.GMW_FAKE_STDOUT = stdoutPath;
process.env.GMW_FAKE_EXIT = "0";
const resolution = await resolveMediaUrl("https://youtu.be/abc");
expect(resolution.title).toBe("Title After Info");
expect(resolution.duration).toBe(123);
});
it("rejects when yt-dlp reports an ERROR on stderr", async () => {
const stderrPath = writeTemp(
"stderr",
"ERROR: [youtube] xyz: This video is unavailable\n",
);
process.env.GMW_FAKE_STDERR = stderrPath;
delete process.env.GMW_FAKE_STDOUT;
process.env.GMW_FAKE_EXIT = "1";
await expect(resolveMediaUrl("https://youtu.be/abc")).rejects.toThrow(
/This video is unavailable/,
);
});
it("rejects when yt-dlp is not installed", async () => {
const oldPath = process.env.PATH;
process.env.PATH = "/usr/bin:/bin"; // no fake yt-dlp
await expect(resolveMediaUrl("https://youtu.be/abc")).rejects.toThrow(
/yt-dlp is not installed/,
);
process.env.PATH = oldPath;
});
});