fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP

The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.7 committed 2026-05-07 19:08:58 +08:00
1 parent 9179780afb
commit 719ceae303
3 files changed
+344 -12

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
// Empirically verifies the PowerShell-download workaround for jdymusic CDN
// blocks Node.js HTTP. Runs A/B against the same fresh /jdymusic/ URL:
// A) ffmpeg direct with browser UA (the previous fix in this branch)
// B) PowerShell WebClient -> temp file -> ffmpeg from file (the new fix)
// Reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { mkdtempSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_jdymusic_powershell.mjs <jdymusic_url>");
process.exit(2);
}
if (!url.includes("/jdymusic/")) {
console.error("warning: this script targets /jdymusic/ URLs specifically");
}
const FFMPEG = "ffmpeg";
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const TIMEOUT_MS = 20_000;
function runFfmpeg(label, args, stdinSource) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: [stdinSource ?? "ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => { bytes += chunk.length; });
proc.stderr.on("data", (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-1500); });
const timer = setTimeout(() => { killed = true; proc.kill("SIGTERM"); }, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
function downloadViaPowerShell(targetUrl, outFile) {
return new Promise((resolve) => {
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: targetUrl,
DL_OUT: outFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stderr = "";
ps.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
ps.on("exit", (code) => resolve({ code, stderr }));
});
}
console.log(`URL: ${url}\n`);
console.log("[A] ffmpeg direct (browser UA via -headers)");
const a = await runFfmpeg("A", buildFfmpegArgs(url, 0));
console.log(` code=${a.code} bytes=${a.bytes} killed=${a.killed}`);
console.log(` stderr-tail: ${a.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
console.log("[B] PowerShell WebClient -> temp file -> ffmpeg -i tempfile");
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-test-"));
const tempFile = join(tempDir, "song.audio");
const psStart = Date.now();
const dl = await downloadViaPowerShell(url, tempFile);
const psMs = Date.now() - psStart;
if (dl.code !== 0) {
console.log(` PowerShell download FAILED: code=${dl.code}`);
console.log(` stderr: ${dl.stderr.slice(-500)}`);
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
const dlSize = statSync(tempFile).size;
console.log(` PowerShell downloaded ${dlSize} bytes in ${psMs}ms`);
const b = await runFfmpeg("B", buildFfmpegArgs(tempFile, 0));
console.log(` ffmpeg-from-file: code=${b.code} bytes=${b.bytes} killed=${b.killed}`);
console.log(` stderr-tail: ${b.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
rmSync(tempDir, { recursive: true, force: true });
const aBlocked = a.bytes === 0 && !a.killed;
const bWorked = b.bytes > 100_000;
console.log(
`Verdict: direct ${aBlocked ? "BLOCKED" : "OK"} ; ` +
`powershell-then-ffmpeg ${bWorked ? "WORKED" : "FAILED"}`,
);
+68 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { buildFfmpegArgs } from "./player.js";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir } from "./player.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
@@ -56,6 +59,16 @@ describe("buildFfmpegArgs", () => {
expect(args).not.toContain("-ss");
});
it("omits HTTP-only flags when input is a local file path", () => {
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
expect(args).not.toContain("-reconnect");
expect(args).not.toContain("-reconnect_on_network_error");
expect(args).not.toContain("-reconnect_on_http_error");
expect(args).not.toContain("-headers");
expect(args).toContain("-i");
expect(args[args.indexOf("-i") + 1]).toBe("C:/temp/song.mp3");
});
it("ends args with the input URL and PCM output spec", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
@@ -66,3 +79,57 @@ describe("buildFfmpegArgs", () => {
expect(args[args.length - 1]).toBe("-");
});
});
describe("shouldUsePowerShellDownload", () => {
const jdymusicUrl =
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
const newCdnUrl =
"http://m801.music.126.net/20260507/abc/jd-musicrep-ts/obj/xyz/song.mp3?vuutv=tok";
const ymusicUrl =
"http://m801.music.126.net/20260507/abc/ymusic/obj/xyz/song.mp3?vuutv=tok";
it("returns true for /jdymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "win32")).toBe(true);
});
it("returns false for /jdymusic/ URL on linux", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "linux")).toBe(false);
});
it("returns false for /jdymusic/ URL on darwin", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "darwin")).toBe(false);
});
it("returns false for new-format /jd-musicrep-ts/ URL on win32", () => {
expect(shouldUsePowerShellDownload(newCdnUrl, "win32")).toBe(false);
});
it("returns false for /ymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(ymusicUrl, "win32")).toBe(false);
});
it("returns false for unrelated URLs", () => {
expect(shouldUsePowerShellDownload("https://example.com/x.mp3", "win32")).toBe(false);
});
});
describe("cleanupTempDir", () => {
it("removes a directory and its contents", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
writeFileSync(join(dir, "song.mp3"), "fake-bytes");
expect(existsSync(dir)).toBe(true);
cleanupTempDir(dir);
expect(existsSync(dir)).toBe(false);
});
it("does not throw when directory does not exist", () => {
const missing = join(tmpdir(), "tsbot-test-does-not-exist-xyz");
expect(() => cleanupTempDir(missing)).not.toThrow();
});
it("does not throw when called twice", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
cleanupTempDir(dir);
expect(() => cleanupTempDir(dir)).not.toThrow();
});
});
+170 -11
View File
@@ -1,7 +1,9 @@
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import { accessSync, chmodSync, constants } from "node:fs";
import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Logger } from "../logger.js";
@@ -52,28 +54,49 @@ function getFfmpegCommand(): string {
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// Old jdymusic CDN paths (e.g. /jdymusic/obj/...) RST direct Node-stack
// requests on Windows; same URL works when fetched via WinHTTP. Empirically,
// /jd-musicrep-ts/ and /ymusic/ paths do not have this restriction.
export function shouldUsePowerShellDownload(
url: string,
platform: string = process.platform,
): boolean {
return platform === "win32" && url.includes("/jdymusic/");
}
export function cleanupTempDir(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url);
if (url.includes("bilivideo") || url.includes("bilibili")) {
if (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
args.push(
"-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
} else if (url.includes("music.126.net") || url.includes("music.163.com")) {
} else if (isHttp && (url.includes("music.126.net") || url.includes("music.163.com"))) {
args.push(
"-headers",
`Referer: https://music.163.com/\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
}
args.push(
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "30",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "4xx,5xx",
);
if (isHttp) {
args.push(
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "30",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "4xx,5xx",
);
}
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
@@ -111,6 +134,8 @@ export class AudioPlayer extends EventEmitter {
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
private downloader: ChildProcess | null = null;
private currentTempDir: string | null = null;
constructor(logger: Logger) {
super();
@@ -137,6 +162,11 @@ export class AudioPlayer extends EventEmitter {
return;
}
if (shouldUsePowerShellDownload(url)) {
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
return;
}
const args = buildFfmpegArgs(url, seekSeconds);
const ffmpegBin = getFfmpegCommand();
@@ -183,6 +213,124 @@ export class AudioPlayer extends EventEmitter {
this.startFrameLoop();
}
private playViaPowerShellDownload(url: string, seekSeconds: number, sessionId: number): void {
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-"));
const tempFile = join(tempDir, "song.audio");
this.currentTempDir = tempDir;
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
this.logger.debug({ sessionId, tempFile }, "Downloading via PowerShell (jdymusic CDN)");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: url,
DL_OUT: tempFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
this.downloader = ps;
let stderrTail = "";
ps.stderr!.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-500);
});
ps.on("exit", (code, signal) => {
if (this.sessionId !== sessionId) {
cleanupTempDir(tempDir);
return;
}
this.downloader = null;
if (code !== 0) {
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
this.spawnFailed = true;
this.consecutiveFailures++;
this.state = "idle";
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", new Error(`PowerShell download exited ${code}`));
return;
}
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
});
ps.on("error", (err) => {
if (this.sessionId !== sessionId) return;
this.downloader = null;
this.spawnFailed = true;
this.consecutiveFailures++;
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", err);
});
this.state = "playing";
this.startFrameLoop();
}
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
if (this.sessionId !== sessionId) {
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
return;
}
const args = buildFfmpegArgs(tempFile, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId }, "FFmpeg spawned (from temp file)");
}
const tempDirToCleanup = this.currentTempDir;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (this.sessionId !== sessionId) return;
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
if (this.sessionId === sessionId) {
this.ffmpeg = null;
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
}
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === sessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
}
stop(): void {
// 3. 递增 ID 是最有效的逻辑“隔离墙”
this.sessionId++;
@@ -194,13 +342,24 @@ export class AudioPlayer extends EventEmitter {
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
if (this.downloader) {
const ps = this.downloader;
this.downloader = null;
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
}
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";