diff --git a/scripts/test_netease_ua_fix.mjs b/scripts/test_netease_ua_fix.mjs new file mode 100644 index 0000000..0a33d68 --- /dev/null +++ b/scripts/test_netease_ua_fix.mjs @@ -0,0 +1,73 @@ +// Empirically tests whether the browser UA + Referer headers fix the +// connection resets we saw in bot.log against m701/m801.music.126.net. +// +// Spawns ffmpeg twice against the SAME fresh Netease CDN URL: +// A) old args from before the fix (no headers, -reconnect_delay_max 5) +// B) new args from after the fix (browser UA + Referer for music.126.net) +// and reports bytes received + exit code + stderr-tail for each. + +import { spawn } from "node:child_process"; +import { buildFfmpegArgs } from "../dist/audio/player.js"; + +const url = process.argv[2]; +if (!url) { + console.error("usage: node scripts/test_netease_ua_fix.mjs "); + process.exit(2); +} + +const FFMPEG = "ffmpeg"; +const TIMEOUT_MS = 15_000; + +function legacyArgs(u) { + return [ + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "5", + "-i", u, + "-f", "s16le", + "-ar", "48000", + "-ac", "2", + "-acodec", "pcm_s16le", + "-", + ]; +} + +function runFfmpeg(label, args) { + return new Promise((resolve) => { + const proc = spawn(FFMPEG, args, { stdio: ["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 }); + }); + }); +} + +console.log(`URL: ${url}\n`); + +const a = await runFfmpeg("A) legacy args (no UA)", legacyArgs(url)); +console.log(`[A] code=${a.code} signal=${a.signal} killed=${a.killed} bytes=${a.bytes}`); +console.log(` stderr-tail:\n${a.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`); + +const b = await runFfmpeg("B) fixed args (browser UA + Referer)", buildFfmpegArgs(url, 0)); +console.log(`[B] code=${b.code} signal=${b.signal} killed=${b.killed} bytes=${b.bytes}`); +console.log(` stderr-tail:\n${b.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`); + +const aFailed = a.bytes === 0 && !a.killed && a.code !== 0; +const bWorked = b.bytes > 100_000; // got real audio bytes +console.log(`Verdict: legacy ${aFailed ? "FAILED (no bytes, exit code 1)" : "??"} ; fixed ${bWorked ? "WORKED (received audio)" : "??"}`); diff --git a/src/audio/player.test.ts b/src/audio/player.test.ts new file mode 100644 index 0000000..4c72145 --- /dev/null +++ b/src/audio/player.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { buildFfmpegArgs } from "./player.js"; + +function getHeadersArg(args: string[]): string { + const idx = args.indexOf("-headers"); + if (idx === -1) return ""; + return args[idx + 1] ?? ""; +} + +describe("buildFfmpegArgs", () => { + it("includes browser User-Agent and Referer for Netease CDN URLs", () => { + const url = "http://m701.music.126.net/some/path/song.mp3?vuutv=abc"; + const args = buildFfmpegArgs(url, 0); + const headers = getHeadersArg(args); + expect(headers).toContain("User-Agent:"); + expect(headers).toContain("Mozilla/5.0"); + expect(headers).toContain("Referer: https://music.163.com/"); + }); + + it("keeps Bilibili Referer + UA for bilibili URLs", () => { + const url = "https://upos-sz-mirrorcoso1.bilivideo.com/foo/bar.mp3"; + const args = buildFfmpegArgs(url, 0); + const headers = getHeadersArg(args); + expect(headers).toContain("Referer: https://www.bilibili.com"); + expect(headers).toContain("User-Agent: Mozilla/5.0"); + }); + + it("does not set custom headers for unknown URLs", () => { + const url = "https://example.com/song.mp3"; + const args = buildFfmpegArgs(url, 0); + expect(args).not.toContain("-headers"); + }); + + it("includes resilient reconnect flags for all URLs", () => { + const args = buildFfmpegArgs("https://example.com/song.mp3", 0); + expect(args).toContain("-reconnect"); + expect(args).toContain("-reconnect_streamed"); + expect(args).toContain("-reconnect_delay_max"); + expect(args).toContain("-reconnect_on_network_error"); + expect(args).toContain("-reconnect_on_http_error"); + const idx = args.indexOf("-reconnect_delay_max"); + expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30); + }); + + it("inserts -ss before -i when seekSeconds > 0", () => { + const args = buildFfmpegArgs("https://example.com/song.mp3", 42); + const ssIdx = args.indexOf("-ss"); + const iIdx = args.indexOf("-i"); + expect(ssIdx).toBeGreaterThan(-1); + expect(args[ssIdx + 1]).toBe("42"); + expect(ssIdx).toBeLessThan(iIdx); + }); + + it("does not insert -ss when seekSeconds is 0", () => { + const args = buildFfmpegArgs("https://example.com/song.mp3", 0); + expect(args).not.toContain("-ss"); + }); + + it("ends args with the input URL and PCM output spec", () => { + const url = "https://example.com/song.mp3"; + const args = buildFfmpegArgs(url, 0); + const iIdx = args.indexOf("-i"); + expect(args[iIdx + 1]).toBe(url); + expect(args).toContain("-f"); + expect(args).toContain("s16le"); + expect(args[args.length - 1]).toBe("-"); + }); +}); diff --git a/src/audio/player.ts b/src/audio/player.ts index aeacecb..6555a1d 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -49,6 +49,37 @@ function getFfmpegCommand(): string { return resolvedFfmpeg; } +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"; + +export function buildFfmpegArgs(url: string, seekSeconds: number): string[] { + const args: string[] = []; + + if (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")) { + 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 (seekSeconds > 0) args.push("-ss", String(seekSeconds)); + args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"); + + return args; +} + export interface PlayerEvents { frame: (opusFrame: Buffer) => void; trackEnd: () => void; @@ -106,13 +137,7 @@ export class AudioPlayer extends EventEmitter { return; } - const args: string[] = []; - if (url.includes("bilivideo") || url.includes("bilibili")) { - args.push("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n"); - } - args.push("-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"); - if (seekSeconds > 0) args.push("-ss", String(seekSeconds)); - args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"); + const args = buildFfmpegArgs(url, seekSeconds); const ffmpegBin = getFfmpegCommand(); this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });