mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 21:12:49 +08:00
feat(local): 支持上传并播放本地视频文件,只保留音轨 (#149)
本地上传此前只接受音频。想放一段本地 mp4/mov/avi 里的音乐,四道关卡 挡着(前两道在服务端,后两道在浏览器端): 1. src/music/local.ts 的 AUDIO_EXTENSIONS 只列了 12 种音频后缀; 2. src/web/api/music.ts 里 express.raw 的 type 只匹配 audio/*、 video/webm、application/octet-stream —— 浏览器给 .mp4 打的 Content-Type 是 video/mp4,请求体压根不会被解析,处理函数看到 req.body === undefined,回 400「raw audio body is required」; 3. Search.vue 的 accept 属性让文件选择框把视频文件置灰; 4. isAudioFile() 把拖进来的视频文件静默丢掉。 ffmpeg 层不是瓶颈:s16le 输出格式不接受视频,ffmpeg 的自动选流本来 就只挑音轨。实测 mp4/mov/avi/mkv/flv/wmv/ts/m4v/mpg 九种容器用现有 参数全部正常出声,多音轨、带字幕、带 timecode 的也一样,所以 buildFfmpegArgs 一个字没动。 ## 改动 - **打通四道关卡**:新增 VIDEO_EXTENSIONS(mp4/mov/avi/mkv/flv/wmv/ m4v/mpg/mpeg/3gp/ts/m2ts/ogv),express.raw 收 video/*,前端 accept 与过滤函数同步放宽。 - **上传时抽取音轨**(extractAudioTrack):视频落盘后用 `-vn -sn -dn -map 0:a:0 -c:a copy` 把音轨原样搬进 Matroska 音频容器 (.mka)再删掉原视频。`-c:a copy` 不重编码,无损、快,且 Matroska 几乎收所有音频编码,不用维护「编码→后缀」对照表。实测 720p 素材 落盘体积降到原文件的 14%,这对 5 GiB 的上传目录配额很关键——否则 十来个视频就把配额占满了。抽取失败(冷门编码、超时)则保留原容器 继续播,只是占地方,绝不会因此上传失败。 - **拒绝没有音轨的视频**:上传时探测,直接回「这个视频里没有音轨, 无法播放」,而不是等到播放时静默跳过。只在 ffmpeg 确实打开了容器 (打印了 `Input #0,`)时才拒绝——认不出的字节一律放行,截断的 mp3 一直是这个行为,不能因为这次改动开始被拒。 - **上限从 200mb 提到 500mb**,并把超限响应从 Express 默认的 HTML 错误页(带堆栈和服务器绝对路径)换成和本路由一致的 JSON;前端也加 了同样的预检,不再传完几百兆才被拒。 - **上传进度**:视频比音频大得多,原来那句静止的「正在上传 N 个文件」 看着像卡死,现在按文件显示百分比,传完切到「服务端处理中」。 ## 验证 - 全量 `npx vitest run`:136 个文件 / 2070 项,新增 24 项。 - 新增测试用 ffmpeg 现造真实容器跑端到端:mp4 上传后时长正确、原 容器已删、剩下的 .mka 能被播放链路解码出 PCM;avi/mkv/flv 同样; 无音轨视频被拒且不留残留文件;纯音频上传字节数不变、不被重封装。 - 变异测试(逐个改回旧实现,确认新测试真的会红):后缀白名单 4 项失败、 express.raw 的 type 5 项失败、抽取音轨 2 项失败、无音轨拒绝 2 项失败。 - `npx tsc --noEmit` 与 `npx vue-tsc --noEmit` 均 exit 0。 Reported-by: @LadenceE Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b92543f337
commit
28b3cd771f
6 files changed
+651
-42
No files matched your search
+144
-1
@@ -14,7 +14,7 @@ import { createSessionStore } from "../../data/sessions.js";
|
||||
import { createPermissionStore } from "../../data/permissions.js";
|
||||
import { createRequireAuth } from "../middleware/requireAuth.js";
|
||||
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
|
||||
import { createMusicRouter } from "./music.js";
|
||||
import { createMusicRouter, createLocalUploadBody } from "./music.js";
|
||||
|
||||
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
|
||||
|
||||
@@ -295,3 +295,146 @@ describe("music router POST /quality — persistence (#125)", () => {
|
||||
expect(config.audioQuality.netease).toBe("lossless");
|
||||
});
|
||||
});
|
||||
|
||||
// #149: video containers must survive the transport layer. Before this the
|
||||
// express.raw type filter only matched audio/*, video/webm and
|
||||
// application/octet-stream, so a browser-sent video/mp4 body was never parsed
|
||||
// and the handler answered 400 "raw audio body is required".
|
||||
describe("music router POST /local/upload — content types and size cap (#149)", () => {
|
||||
let app: express.Express;
|
||||
let botDb: BotDatabase;
|
||||
let cookie: string;
|
||||
let uploadAudio: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
botDb = createDatabase(":memory:");
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const admin = await users.createUser("admin", "pw-admin", "admin");
|
||||
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
|
||||
|
||||
uploadAudio = vi.fn(async (input: { originalName: string }) => ({
|
||||
id: "local-1", name: input.originalName, artist: "本地上传", album: "本地音乐",
|
||||
duration: 1, coverUrl: "", platform: "local",
|
||||
}));
|
||||
const local = { platform: "local", search: vi.fn().mockResolvedValue(empty), uploadAudio } as unknown as MusicProvider;
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app.use("/api/music", createMusicRouter(
|
||||
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
|
||||
pino({ level: "silent" }), local, getDefaultConfig(),
|
||||
));
|
||||
});
|
||||
|
||||
afterEach(() => botDb.close());
|
||||
|
||||
const post = (contentType: string, body: Buffer, name = "clip.mp4") =>
|
||||
request(app)
|
||||
.post("/api/music/local/upload")
|
||||
.set("Cookie", cookie)
|
||||
.set("Content-Type", contentType)
|
||||
.set("X-Filename", encodeURIComponent(name))
|
||||
.send(body);
|
||||
|
||||
it("accepts the video MIME types browsers actually send", async () => {
|
||||
// These are what Chrome/Firefox put on a File for .mp4/.mov/.avi/.mkv.
|
||||
for (const ct of ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/webm"]) {
|
||||
uploadAudio.mockClear();
|
||||
const res = await post(ct, Buffer.from("fake video bytes"));
|
||||
expect(res.status, `content-type ${ct}`).toBe(200);
|
||||
expect(uploadAudio).toHaveBeenCalledOnce();
|
||||
expect(res.body.song.platform).toBe("local");
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts audio and octet-stream bodies", async () => {
|
||||
for (const ct of ["audio/mpeg", "audio/flac", "application/octet-stream"]) {
|
||||
uploadAudio.mockClear();
|
||||
const res = await post(ct, Buffer.from("fake audio"), "tune.mp3");
|
||||
expect(res.status, `content-type ${ct}`).toBe(200);
|
||||
expect(uploadAudio).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the decoded filename and the content type through to the provider", async () => {
|
||||
await post("video/mp4", Buffer.from("bytes"), "我的 视频.mp4");
|
||||
expect(uploadAudio).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ originalName: "我的 视频.mp4", mimeType: "video/mp4" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces a provider rejection as a 400 with its message", async () => {
|
||||
uploadAudio.mockRejectedValueOnce(new Error("这个视频里没有音轨,无法播放"));
|
||||
const res = await post("video/mp4", Buffer.from("bytes"));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("这个视频里没有音轨,无法播放");
|
||||
});
|
||||
|
||||
it("requires authentication", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/music/local/upload")
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.from("bytes"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an oversize body as JSON, not an HTML stack trace", async () => {
|
||||
// Same middleware the route mounts, built with a small limit so the test
|
||||
// does not have to allocate half a gigabyte to reach the cap.
|
||||
const tiny = express();
|
||||
const reached = vi.fn();
|
||||
tiny.post("/u", createLocalUploadBody("1kb"), (_req, res) => { reached(); res.json({ ok: true }); });
|
||||
|
||||
const res = await request(tiny)
|
||||
.post("/u")
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.alloc(4096, 1));
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.headers["content-type"]).toMatch(/application\/json/);
|
||||
expect(res.body.error).toContain("文件太大");
|
||||
// The HTML default handler leaked absolute server paths and a stack.
|
||||
expect(res.text).not.toMatch(/node_modules|<\/pre>|at read/);
|
||||
expect(reached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a body under the cap through the same middleware", async () => {
|
||||
const tiny = express();
|
||||
tiny.post("/u", createLocalUploadBody("1kb"), (req, res) => {
|
||||
res.json({ bytes: (req.body as Buffer).length });
|
||||
});
|
||||
const res = await request(tiny)
|
||||
.post("/u")
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.alloc(512, 1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bytes).toBe(512);
|
||||
});
|
||||
|
||||
it("rejects local uploads when the feature is switched off", async () => {
|
||||
const off = getDefaultConfig();
|
||||
off.localAudioEnabled = false;
|
||||
const users = createUserStore(botDb.db);
|
||||
const sessions = createSessionStore(botDb.db);
|
||||
const a2 = await users.createUser("admin2", "pw-admin2", "admin");
|
||||
const c2 = `${SESSION_COOKIE_NAME}=${sessions.createSession(a2.id).token}`;
|
||||
const app2 = express();
|
||||
app2.use(cookieParser());
|
||||
app2.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
|
||||
app2.use("/api/music", createMusicRouter(
|
||||
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
|
||||
pino({ level: "silent" }),
|
||||
{ platform: "local", search: vi.fn(), uploadAudio } as unknown as MusicProvider, off,
|
||||
));
|
||||
const res = await request(app2)
|
||||
.post("/api/music/local/upload")
|
||||
.set("Cookie", c2)
|
||||
.set("Content-Type", "video/mp4")
|
||||
.send(Buffer.from("bytes"));
|
||||
expect(res.status).toBe(403);
|
||||
expect(uploadAudio).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+51
-4
@@ -7,6 +7,56 @@ import { requirePermission } from "../middleware/requirePermission.js";
|
||||
import { requireNotGuest } from "../middleware/requireNotGuest.js";
|
||||
import { authorize } from "../middleware/authorize.js";
|
||||
|
||||
/**
|
||||
* Body cap for a local upload. express.raw buffers the whole body in memory,
|
||||
* so this is also the peak RAM one upload can cost — raised from 200mb for
|
||||
* video (#149), which is far bigger than audio for the same song, but kept
|
||||
* well short of "any video file at all" for that reason. Only the audio track
|
||||
* survives to disk.
|
||||
*/
|
||||
export const LOCAL_UPLOAD_LIMIT = "500mb";
|
||||
|
||||
/**
|
||||
* Body parser for the local-upload route.
|
||||
*
|
||||
* `type` includes "video/*" (#149): the browser sends the File's own MIME
|
||||
* type, so an .mp4 arrives as video/mp4 and used to be rejected by this
|
||||
* filter before ever reaching the provider. Only the audio track is kept —
|
||||
* uploadAudio remuxes it out on the way in.
|
||||
*
|
||||
* express.raw hands an oversize body to the default error handler, which
|
||||
* answers with an HTML page carrying a stack trace and absolute server paths
|
||||
* (unless NODE_ENV=production, which this project never sets). Video makes
|
||||
* hitting the cap far more likely than audio did, so that one case is
|
||||
* translated into the same JSON shape the rest of this route returns. Any
|
||||
* other body-parser error is passed on untouched.
|
||||
*
|
||||
* Exported as a factory so tests can drive the identical path with a small
|
||||
* limit instead of allocating half a gigabyte.
|
||||
*/
|
||||
export function createLocalUploadBody(limit: string): express.RequestHandler {
|
||||
const raw = express.raw({
|
||||
type: ["audio/*", "video/*", "application/octet-stream"],
|
||||
limit,
|
||||
});
|
||||
return (req, res, next) => {
|
||||
raw(req, res, (err?: unknown) => {
|
||||
if (!err) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const e = err as { type?: string; status?: number };
|
||||
if (e?.type === "entity.too.large" || e?.status === 413) {
|
||||
res.status(413).json({ error: `文件太大,单个文件上限 ${limit}` });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const localUploadBody = createLocalUploadBody(LOCAL_UPLOAD_LIMIT);
|
||||
|
||||
export function createMusicRouter(
|
||||
neteaseProvider: MusicProvider,
|
||||
qqProvider: MusicProvider,
|
||||
@@ -65,10 +115,7 @@ export function createMusicRouter(
|
||||
}
|
||||
next();
|
||||
},
|
||||
express.raw({
|
||||
type: ["audio/*", "video/webm", "application/octet-stream"],
|
||||
limit: "200mb",
|
||||
}),
|
||||
localUploadBody,
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (!localProvider) {
|
||||
|
||||
Reference in new issue
Block a user