mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(player): set browser UA + Referer for Netease CDN to stop auto-skip
Netease's m701/m801.music.126.net CDN RST connections from FFmpeg's default Lavf UA. The bot's frame loop treats an early ffmpeg exit as trackEnd and advances the queue, which surfaced as songs auto-skipping mid-playlist. Add a browser UA + music.163.com Referer for these URLs, extract args into a tested buildFfmpegArgs(), and harden reconnect flags (delay_max 5 -> 30, plus reconnect_on_network_error / reconnect_on_http_error). Verified by A/B running ffmpeg against the same fresh CDN URL: legacy args got 0 bytes + "End of file" reading HTTP response; fixed args streamed the full track to completion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7eb96d9068
commit
f76500cfb2
3 files changed
+173
-7
No files matched your search
@@ -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 <netease_cdn_url>");
|
||||||
|
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)" : "??"}`);
|
||||||
@@ -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("-");
|
||||||
|
});
|
||||||
|
});
|
||||||
+32
-7
@@ -49,6 +49,37 @@ function getFfmpegCommand(): string {
|
|||||||
return resolvedFfmpeg;
|
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 {
|
export interface PlayerEvents {
|
||||||
frame: (opusFrame: Buffer) => void;
|
frame: (opusFrame: Buffer) => void;
|
||||||
trackEnd: () => void;
|
trackEnd: () => void;
|
||||||
@@ -106,13 +137,7 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const args: string[] = [];
|
const args = buildFfmpegArgs(url, seekSeconds);
|
||||||
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 ffmpegBin = getFfmpegCommand();
|
const ffmpegBin = getFfmpegCommand();
|
||||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
|||||||
Reference in new issue
Block a user