From 719ceae303c7f41f5a4f2ae0d291383cffd47733 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 7 May 2026 19:08:00 +0800 Subject: [PATCH] fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/test_jdymusic_powershell.mjs | 106 ++++++++++++++++ src/audio/player.test.ts | 69 +++++++++- src/audio/player.ts | 181 +++++++++++++++++++++++++-- 3 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 scripts/test_jdymusic_powershell.mjs diff --git a/scripts/test_jdymusic_powershell.mjs b/scripts/test_jdymusic_powershell.mjs new file mode 100644 index 0000000..639f259 --- /dev/null +++ b/scripts/test_jdymusic_powershell.mjs @@ -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 "); + 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"}`, +); diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts index 4c72145..21dd5be 100644 --- a/src/audio/player.test.ts +++ b/src/audio/player.test.ts @@ -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(); + }); +}); diff --git a/src/audio/player.ts b/src/audio/player.ts index 6555a1d..5b1e6e0 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -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";