134 lines
5.6 KiB
TypeScript
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;
|
||
|
|
});
|
||
|
|
});
|