Files
teamspeak-music-bot/src/web/api/music.test.ts
T
saopig1andClaude Opus 5 28b3cd771f 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>
2026-08-14 01:20:01 +08:00

441 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { MusicProvider, SearchResult } from "../../music/provider.js";
import { getDefaultConfig, loadConfig, type BotConfig } from "../../data/config.js";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
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, createLocalUploadBody } from "./music.js";
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
function fakeProvider(platform: MusicProvider["platform"]): MusicProvider {
return {
platform,
search: vi.fn().mockResolvedValue(empty),
} as unknown as MusicProvider;
}
describe("music router GET /search offset pagination", () => {
let app: express.Express;
let netease: MusicProvider;
beforeEach(() => {
netease = fakeProvider("netease");
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
app = express();
app.use("/api/music", router);
});
it("parses offset and passes it as the 3rd arg to provider.search", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 20);
});
it("defaults a missing offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("clamps a negative offset to 0", async () => {
const res = await request(app).get("/api/music/search?q=hello&limit=20&offset=-5");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
});
});
describe("music router provider gating (enabledProviders) + jellyfin endpoints", () => {
function jellyfinFake(): MusicProvider {
return {
platform: "jellyfin",
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn().mockReturnValue("direct"),
getLatestAlbums: vi
.fn()
.mockResolvedValue([{ id: "a1", name: "Album", platform: "jellyfin" }]),
getFavoriteSongs: vi.fn().mockResolvedValue([]),
} as unknown as MusicProvider;
}
function mount(config: BotConfig) {
const netease = fakeProvider("netease");
const jellyfin = jellyfinFake();
const router = createMusicRouter(
netease,
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" }),
undefined,
config,
fakeProvider("kugou"),
fakeProvider("spotify"),
jellyfin,
);
const app = express();
app.use("/api/music", router);
return { app, netease, jellyfin };
}
it("routes a platform-less /search to the default platform (netease)", async () => {
const { app, netease, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200);
expect(netease.search).toHaveBeenCalledWith("hello", 20, 0);
expect(jellyfin.search).not.toHaveBeenCalled();
});
it("rejects a disabled platform with 400 without calling its provider", async () => {
// Default config leaves jellyfin (opt-in) disabled.
const { app, jellyfin } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(400);
expect(jellyfin.search).not.toHaveBeenCalled();
});
it("allows an explicitly enabled jellyfin platform", async () => {
const config = getDefaultConfig();
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
const { app, jellyfin } = mount(config);
const res = await request(app).get("/api/music/search?q=hello&platform=jellyfin");
expect(res.status).toBe(200);
expect(jellyfin.search).toHaveBeenCalledWith("hello", 20, 0);
});
it("GET /providers reports enabled sources and the default platform", async () => {
const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200);
expect(res.body.default).toBe("netease");
expect(res.body.enabled).toContain("netease");
expect(res.body.enabled).toContain("local"); // localAudioEnabled defaults on
expect(res.body.enabled).not.toContain("jellyfin"); // opt-in, off by default
expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off
});
it("GET /providers reports a configured defaultPlatform override (#126)", async () => {
const config = getDefaultConfig();
config.defaultPlatform = "qq"; // operator prefers QQ over the priority order
const { app } = mount(config);
const res = await request(app).get("/api/music/providers");
expect(res.status).toBe(200);
expect(res.body.default).toBe("qq");
});
it("routes a platform-less /search to the configured defaultPlatform (#126)", async () => {
const config = getDefaultConfig();
config.defaultPlatform = "bilibili";
const { app, netease } = mount(config);
const res = await request(app).get("/api/music/search?q=hello");
expect(res.status).toBe(200);
// Default is now bilibili, so the netease provider must NOT be hit.
expect(netease.search).not.toHaveBeenCalled();
});
/** Default config plus the opt-in jellyfin source enabled. */
function configWithJellyfin() {
const config = getDefaultConfig();
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
return config;
}
it("GET /jellyfin/latest-albums returns provider data", async () => {
const { app, jellyfin } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/latest-albums?limit=5");
expect(res.status).toBe(200);
expect(
(jellyfin as unknown as { getLatestAlbums: ReturnType<typeof vi.fn> }).getLatestAlbums,
).toHaveBeenCalledWith(5);
expect(res.body.albums).toHaveLength(1);
});
it("GET /jellyfin/latest-albums is 400 when jellyfin is disabled (the default)", async () => {
const { app } = mount(getDefaultConfig());
const res = await request(app).get("/api/music/jellyfin/latest-albums");
expect(res.status).toBe(400);
});
it("GET /jellyfin/favorites denies unauthenticated/guest access", async () => {
const { app } = mount(configWithJellyfin());
const res = await request(app).get("/api/music/jellyfin/favorites");
expect(res.status).toBe(401);
});
});
describe("music router POST /quality — persistence (#125)", () => {
let tmpDir: string;
let configPath: string;
let config: BotConfig;
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let providers: Record<string, MusicProvider>;
/** A provider whose in-memory quality is settable and readable, like the real
* ones. */
function qualityProvider(platform: MusicProvider["platform"], initial: string): MusicProvider {
let q = initial;
return {
platform,
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn(() => q),
setQuality: vi.fn((v: string) => { q = v; }),
} as unknown as MusicProvider;
}
/** Jellyfin only accepts its own tiers (mirrors the real provider), so a
* broadcast of a foreign value is ignored — proving the snapshot captures each
* provider's ACTUAL post-apply state, not just the request value. */
function jellyfinQualityProvider(): MusicProvider {
let q = "direct";
const tiers = new Set(["direct", "320", "192", "128"]);
return {
platform: "jellyfin",
search: vi.fn().mockResolvedValue(empty),
getQuality: vi.fn(() => q),
setQuality: vi.fn((v: string) => { if (tiers.has(v)) q = v; }),
} as unknown as MusicProvider;
}
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}`;
tmpDir = mkdtempSync(join(tmpdir(), "musicquality-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
providers = {
netease: qualityProvider("netease", "exhigh"),
qq: qualityProvider("qq", "exhigh"),
bilibili: qualityProvider("bilibili", "high"),
kugou: qualityProvider("kugou", "128"),
jellyfin: jellyfinQualityProvider(),
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/music",
createMusicRouter(
providers.netease, providers.qq, providers.bilibili, pino({ level: "silent" }),
undefined, config, providers.kugou, undefined, providers.jellyfin, configPath,
),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("persists a platform-specific quality change to config.json", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ platform: "netease", quality: "lossless" });
expect(res.status).toBe(200);
expect(providers.netease.setQuality).toHaveBeenCalledWith("lossless");
// in-memory config mutated
expect(config.audioQuality.netease).toBe("lossless");
// written to disk + reload reflects it (survives a restart)
const onDisk = JSON.parse(readFileSync(configPath, "utf-8"));
expect(onDisk.audioQuality.netease).toBe("lossless");
expect(loadConfig(configPath).audioQuality.netease).toBe("lossless");
});
it("snapshots each provider's post-apply quality on a broadcast change", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ quality: "320" });
expect(res.status).toBe(200);
// Broadcast reached every provider…
expect(providers.netease.setQuality).toHaveBeenCalledWith("320");
expect(providers.jellyfin.setQuality).toHaveBeenCalledWith("320");
// …and the snapshot reflects what each one actually accepted. Jellyfin's
// "320" is a valid tier here, so it takes; a foreign value would be ignored.
expect(config.audioQuality).toEqual({
netease: "320",
qq: "320",
bilibili: "320",
kugou: "320",
jellyfin: "320",
});
});
it("ignores foreign broadcast values that a provider rejects (jellyfin)", async () => {
const res = await request(app)
.post("/api/music/quality")
.set("Cookie", cookie)
.send({ quality: "lossless" });
expect(res.status).toBe(200);
// jellyfin rejects the NetEase-style value → stays at its default tier.
expect(config.audioQuality.jellyfin).toBe("direct");
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();
});
});