mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce88d12a60 | ||
|
|
719ceae303 | ||
|
|
9179780afb | ||
|
|
f76500cfb2 | ||
|
|
7eb96d9068 |
No files matched your search
@@ -0,0 +1,64 @@
|
|||||||
|
name: Build and Publish Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Extra tag to publish (optional, e.g. "edge")'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: zhangtianyao1/teamspeak-music-bot
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract image metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: scripts/docker/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
# TSMusicBot — Docker Compose
|
# TSMusicBot — Docker Compose
|
||||||
# 一键部署:docker-compose up -d
|
# 一键部署:docker-compose pull && docker-compose up -d
|
||||||
#
|
#
|
||||||
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)无需安装任何额外软件
|
# 默认从 GitHub Container Registry 拉取预构建镜像(amd64 + arm64),
|
||||||
# FFmpeg 使用系统包管理器安装,确保容器内兼容性
|
# 无需本地编译,无需 Node.js / 构建工具链。
|
||||||
|
# 镜像内置:Node.js, FFmpeg, Opus 编码器,原生模块均已交叉编译。
|
||||||
|
#
|
||||||
|
# 如需指定版本,把 :latest 换成具体 tag(例如 :1.4.0)。
|
||||||
|
# 如需本地构建(开发或 fork),见底部注释。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tsmusicbot:
|
tsmusicbot:
|
||||||
build:
|
image: ghcr.io/zhangtianyao1/teamspeak-music-bot:latest
|
||||||
context: ../..
|
|
||||||
dockerfile: scripts/docker/Dockerfile
|
|
||||||
container_name: tsmusicbot
|
container_name: tsmusicbot
|
||||||
# Use host network so the bot can reach TS3 server on LAN
|
# Use host network so the bot can reach TS3 server on LAN
|
||||||
# If your TS3 server is on the same machine, this is required
|
# If your TS3 server is on the same machine, this is required
|
||||||
@@ -27,3 +29,12 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
tsmusicbot-data:
|
tsmusicbot-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 本地构建(仅开发 / fork 场景使用):
|
||||||
|
# 把上面的 `image:` 行删掉,换成下面的 build 块即可。
|
||||||
|
#
|
||||||
|
# build:
|
||||||
|
# context: ../..
|
||||||
|
# dockerfile: scripts/docker/Dockerfile
|
||||||
|
# ------------------------------------------------------------------
|
||||||
@@ -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"}`,
|
||||||
|
);
|
||||||
@@ -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,135 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
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");
|
||||||
|
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("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);
|
||||||
|
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("-");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+192
-8
@@ -1,7 +1,9 @@
|
|||||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { createRequire } from "node:module";
|
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 { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
@@ -49,6 +51,58 @@ 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";
|
||||||
|
|
||||||
|
// 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 (isHttp && (url.includes("bilivideo") || url.includes("bilibili"))) {
|
||||||
|
args.push(
|
||||||
|
"-headers",
|
||||||
|
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
|
||||||
|
);
|
||||||
|
} 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "-");
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerEvents {
|
export interface PlayerEvents {
|
||||||
frame: (opusFrame: Buffer) => void;
|
frame: (opusFrame: Buffer) => void;
|
||||||
trackEnd: () => void;
|
trackEnd: () => void;
|
||||||
@@ -80,6 +134,8 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
||||||
private healthyFrames = 0;
|
private healthyFrames = 0;
|
||||||
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||||
|
private downloader: ChildProcess | null = null;
|
||||||
|
private currentTempDir: string | null = null;
|
||||||
|
|
||||||
constructor(logger: Logger) {
|
constructor(logger: Logger) {
|
||||||
super();
|
super();
|
||||||
@@ -106,13 +162,12 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const args: string[] = [];
|
if (shouldUsePowerShellDownload(url)) {
|
||||||
if (url.includes("bilivideo") || url.includes("bilibili")) {
|
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
|
||||||
args.push("-headers", "Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0...\r\n");
|
return;
|
||||||
}
|
}
|
||||||
args.push("-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5");
|
|
||||||
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
|
const args = buildFfmpegArgs(url, 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"] });
|
||||||
@@ -158,6 +213,124 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
this.startFrameLoop();
|
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 {
|
stop(): void {
|
||||||
// 3. 递增 ID 是最有效的逻辑“隔离墙”
|
// 3. 递增 ID 是最有效的逻辑“隔离墙”
|
||||||
this.sessionId++;
|
this.sessionId++;
|
||||||
@@ -169,13 +342,24 @@ export class AudioPlayer extends EventEmitter {
|
|||||||
if (this.ffmpeg) {
|
if (this.ffmpeg) {
|
||||||
const procToKill = this.ffmpeg;
|
const procToKill = this.ffmpeg;
|
||||||
const pidToKill = procToKill.pid;
|
const pidToKill = procToKill.pid;
|
||||||
this.ffmpeg = null;
|
this.ffmpeg = null;
|
||||||
|
|
||||||
if (pidToKill) {
|
if (pidToKill) {
|
||||||
this.forceCleanup(procToKill, 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.ffmpegPaused = false;
|
||||||
this.spawnFailed = false;
|
this.spawnFailed = false;
|
||||||
this.state = "idle";
|
this.state = "idle";
|
||||||
|
|||||||
Reference in new issue
Block a user