Compare commits

...
8 Commits
Author SHA1 Message Date
saopig1andClaude Opus 5 af1dac848d fix(local): remux aac into .m4a so the extracted audio is bit-exact (#149)
Extraction always used Matroska (.mka) because it takes essentially any
audio codec. That is right for most codecs but wrong for AAC: MP4 records
the AAC encoder priming (the ~1000 warm-up samples every AAC encoder emits)
in an edit list, and the edit list does not survive into Matroska. The
remuxed track then decodes ~23 ms longer than the source, with the priming
samples played at the head instead of discarded.

Measured on a 5s 640x480 fixture: source audio decodes to 962980 bytes of
PCM, the .mka to 967440 — 4460 bytes / ~23 ms extra, peaking at -66 dBFS.
Inaudible in practice, but it also puts the track fractionally out of step
with its own reported duration, for no reason.

Pick the container by codec instead: aac -> .m4a (keeps the edit list),
everything else -> .mka as before. If the preferred container refuses the
codec, retry into .mka before falling back to keeping the whole video. AAC
is worth the special case because mp4 / mov / m4v — what people actually
upload — almost always carry it.

Adds the strongest available test of the "lossless" claim: decode the audio
straight out of the source mp4, decode the stored extract, assert the PCM is
byte-for-byte equal. Forcing .mka fails it with exactly the 4460-byte delta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:50:08 +08:00
saopig1andClaude Opus 5 9fdc164f98 test(session): give the change-password case a timeout that fits its work
The bcrypt change-password case runs six bcryptjs rounds (one hash to create
the user, four verifies, one hash for the new password). bcryptjs is pure JS,
so it takes ~4.5s on an idle machine against vitest's 5s default — and tipped
over whenever the full suite saturated the CPU. It read as an intermittent
failure but the work is genuinely slow, not hung.

The new #149 tests spawn real ffmpeg processes, which added enough CPU
pressure to turn an occasional flake into a near-every-run failure, so fix it
rather than leave a suite that cries wolf.

Raise this one case to 20s. Suite is now stably green across repeated full
runs: 138 files / 2109 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:40:36 +08:00
saopig1andClaude Opus 5 19ad48c4ab fix(local): keep the original size when the source video can't be deleted (#149)
After extracting the audio track, uploadAudio assigned the record's `size`
from the new .mka BEFORE deleting the source video:

    size = statSync(extracted).size;
    rmSync(filePath, { force: true });   // can throw EBUSY/EPERM on Windows
    filePath = extracted;

rmSync with force:true only swallows ENOENT — a briefly locked file (exactly
what the existing scheduleRetry machinery in this file exists to handle)
throws. The catch then discards the extract and keeps playing the original
container, which is correct, but `size` had already been overwritten with the
much smaller extracted size while the whole video stayed on disk. That makes
totalBytes() under-count and lets the upload directory grow past its quota.

Commit filePath and size together, only once the source is actually gone.

Adds a regression test that partially mocks node:fs to make rmSync throw for
the source .mp4 and asserts the persisted record (index.json — `size` is not
exposed through search()/toSong) still describes the retained file. With the
old ordering it records 27894 bytes for a 104544-byte file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:40:23 +08:00
saopig1andClaude Opus 5 c79a9a6dee docs: add v1.13.0 changelog entry
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:26:31 +08:00
TIANYAO ZHANG ea0d7abce3 Merge pull request #151 from ZHANGTIANYAO1/feat/local-video-playback
feat(local): 支持上传并播放本地视频文件(只保留音轨)
2026-08-14 01:23:02 +08:00
TIANYAO ZHANG c7b577adba Merge pull request #150 from ZHANGTIANYAO1/fix/avatar-upload-before-connect
fix(avatar): 初始化阶段不再发起注定失败的头像上传
2026-08-14 01:22:58 +08:00
saopig1andClaude Opus 5 7c3926a2ae fix(avatar): don't fire a doomed avatar upload before TeamSpeak connects (#148)
BotInstance loads the persisted custom avatar in its constructor and handed
it to profileManager.setCustomAvatar(). On an idle bot that method
immediately starts the three-step file transfer
(fileTransferInitUpload -> uploadFileData -> clientupdate) — but the
constructor runs long before tsClient.connect(), so TS3Client.client is
still null and the very first step throws "Not connected".

Scope of the bug: setCustomAvatar stores the buffer before attempting the
upload, and profileManager.onConnect() re-applies this.customAvatar once the
handshake completes, so the avatar itself did end up on the server. What the
premature call actually cost was a guaranteed-to-fail file transfer plus a
"Profile update failed" warning on every bot start — and every restart, since
manager.startBot() tears the instance down and reconstructs it. ("Not
connected" is not in handleFeatureError's unrecoverable list, so it never
disabled the avatar feature.)

Add loadCustomAvatar(), which only stores the buffer, and use it at the
constructor call site. onConnect() was already doing the real work, so
nothing is lost. Guard on length > 0 as well: avatarStore.write() is
delete-then-write, so a crash mid-write leaves a 0-byte file, and a 0-byte
Buffer is truthy — previously that took setCustomAvatar's else branch and
fired two more doomed calls (fileTransferDeleteFile + a clear).

setCustomAvatar keeps its immediate-apply behaviour, so editing the avatar
from the WebUI on a live bot still takes effect right away.

Reported-by: @shenmu-rua
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:20:44 +08:00
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
14 changed files with 1717 additions and 46 deletions

No files matched your search

+21 -2
View File
@@ -32,7 +32,7 @@
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明) - **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **本地音视频上传播放** — 在搜索页拖拽或选择本地文件上传,音频(mp3 / flac / wav / m4a / ogg / opus 等)和视频(mp4 / mov / avi / mkv / flv / wmv 等)都支持,视频上传后只保留其中的音轨;上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源) - **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
@@ -884,7 +884,26 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。 > 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本 — v1.12.0:网站图标 / 移动端交互 / 安装脚本按 ABI 自愈 ### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
**本地视频上传播放([#149](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/149),[PR #151](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/151),感谢 [@LadenceE](https://github.com/LadenceE))**
- 搜索页的本地上传现在也收**视频文件**:mp4 / mov / avi / mkv / flv / wmv / m4v / mpg / mpeg / 3gp / ts / m2ts / ogv。上传后当普通歌曲用——直接播放、下一首播放、加入队列都一样。
- 视频**只保留音轨**:上传后立刻把音频流原样搬进一个音频容器(不重编码、无损),再删掉原视频。720p 素材实测落盘只剩原文件的 14%,不然十几个视频就把 5 GiB 的上传目录配额占满了。
- 没有音轨的视频会在**上传时**就被拒绝并说明原因,而不是排进队列后静默跳过。
- 单文件上限从 200 MB 提到 **500 MB**;超限时的报错从 Express 默认的 HTML 错误页(带堆栈和服务器绝对路径)换成正常的中文提示,浏览器端也会在开传前就拦下超大文件。
- 上传进度按文件显示百分比,传完切到「服务端处理中」——视频比音频大得多,原先那句静止的「正在上传」看着像卡死。
- 说明:这里做的是「把你本地磁盘上的文件传上来播放」。让机器人直接读取**服务器**磁盘上任意路径的文件没有做——那等于开一个全盘任意文件读取的口子,而「播放服务器上已有的媒体库」用 Jellyfin 音源即可。
**初始化阶段不再发起注定失败的头像上传([#148](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/148),[PR #150](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/150),感谢 [@shenmu-rua](https://github.com/shenmu-rua))**
- 机器人构造阶段读出已保存的自定义头像后会立刻发起文件传输,但那时 TeamSpeak 还没连上,这次传输必定失败。现在构造阶段只把头像数据装入内存,实际上传交给连接成功后的 `onConnect()`。
- **影响范围说明**:头像本身一直是能正常显示的(连接成功后本来就会重新应用一次),所以这不是「头像丢了」。真正的代价是每次启动 / 重启都会多一次注定失败的请求和一条 `Profile update failed` 警告日志——现在没有了。
- 顺带修掉一个边角:头像文件写到一半崩溃会留下 0 字节文件,原先这会再触发两个同样注定失败的请求。
### v1.12.0:网站图标 / 移动端交互 / 安装脚本按 ABI 自愈
一次性处理了 6 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;`!play id:<id>` 等旧写法全部继续可用。 一次性处理了 6 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;`!play id:<id>` 等旧写法全部继续可用。
+591
View File
@@ -0,0 +1,591 @@
diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts
index b2adb3d..ecc2202 100644
--- a/src/music/netease.test.ts
+++ b/src/music/netease.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { parseLyrics } from "./netease.js";
+import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
+
+ it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
+ const raw = [
+ {
+ id: 42,
+ name: "Album A",
+ picUrl: "https://x/p.jpg",
+ artists: [{ name: "Artist X" }, { name: "Featured Y" }],
+ size: 12,
+ },
+ {
+ id: 99,
+ name: "Album B",
+ picUrl: "",
+ artists: [],
+ },
+ ];
+ expect(mapNeteaseAlbums(raw)).toEqual([
+ { id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
+ { id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
+ ]);
+ });
+
+ it("mapNeteaseAlbums returns [] for empty/null input", () => {
+ expect(mapNeteaseAlbums([])).toEqual([]);
+ expect(mapNeteaseAlbums(null as any)).toEqual([]);
+ expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
+ });
});
diff --git a/src/music/netease.ts b/src/music/netease.ts
index 8aaeab6..4a863db 100644
--- a/src/music/netease.ts
+++ b/src/music/netease.ts
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
+ Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
+export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((a) => ({
+ id: String(a.id),
+ name: a.name ?? "",
+ artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
+ coverUrl: a.picUrl ?? "",
+ songCount: a.size ?? 0,
+ platform: "netease",
+ }));
+}
+
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
- const [songRes, playlistRes] = await Promise.all([
+ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -103,6 +116,9 @@ export class NeteaseProvider implements MusicProvider {
...this.cookieParams,
},
}),
+ this.api.get("/cloudsearch", {
+ params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
+ }),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
- return { songs, playlists, albums: [] };
+ const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
+
+ return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts
new file mode 100644
index 0000000..4f606cf
--- /dev/null
+++ b/src/music/qq.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from "vitest";
+import { mapQqAlbums } from "./qq.js";
+
+describe("QQ adapter", () => {
+ it("mapQqAlbums maps albumMID-style raw entries", () => {
+ const raw = [
+ {
+ albumMID: "abc",
+ albumName: "Aero",
+ singerName: "Singer A",
+ },
+ {
+ albumMID: "xyz",
+ albumName: "Beta",
+ singer: [{ name: "Singer B" }, { name: "Singer C" }],
+ },
+ ];
+ const out = mapQqAlbums(raw);
+ expect(out).toHaveLength(2);
+ expect(out[0]).toMatchObject({
+ id: "abc",
+ name: "Aero",
+ artist: "Singer A",
+ platform: "qq",
+ });
+ expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
+ expect(out[1].artist).toBe("Singer B / Singer C");
+ expect(out[1].coverUrl).toContain("xyz");
+ });
+
+ it("mapQqAlbums returns [] for empty/null input", () => {
+ expect(mapQqAlbums([])).toEqual([]);
+ expect(mapQqAlbums(null as any)).toEqual([]);
+ expect(mapQqAlbums(undefined as any)).toEqual([]);
+ });
+
+ it("mapQqAlbums falls back to albumPic when no albumMID", () => {
+ const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
+ const out = mapQqAlbums(raw);
+ expect(out[0].coverUrl).toBe("https://x/p.jpg");
+ expect(out[0].id).toBe("");
+ });
+});
diff --git a/src/music/qq.ts b/src/music/qq.ts
index 9c0360d..1e7a8ae 100644
--- a/src/music/qq.ts
+++ b/src/music/qq.ts
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
+ Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
@@ -27,6 +28,26 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
+export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((a) => {
+ const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
+ const artist = a.singerName
+ ?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
+ const coverUrl = id
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
+ : (a.albumPic ?? "");
+ return {
+ id,
+ name: a.albumName ?? a.title ?? "",
+ artist,
+ coverUrl,
+ songCount: a.song_count ?? a.songCount ?? 0,
+ platform: "qq" as const,
+ };
+ });
+}
+
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -65,11 +86,12 @@ export class QQMusicProvider implements MusicProvider {
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
- param: {
- searchid: "1",
- query,
- num_per_page: Math.min(limit, 50),
- },
+ param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
+ },
+ req_album: {
+ module: "music.search.SearchCgiService",
+ method: "DoSearchForQQMusicDesktop",
+ param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
@@ -90,7 +112,10 @@ export class QQMusicProvider implements MusicProvider {
platform: "qq",
}));
- return { songs, playlists: [], albums: [] };
+ const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
+ const albums = mapQqAlbums(albumList);
+
+ return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
index b08f9a2..edf9c04 100644
--- a/src/web/api/music.ts
+++ b/src/web/api/music.ts
@@ -52,14 +52,20 @@ export function createMusicRouter(
]);
const songs = [
- ...(neteaseResult.status === "fulfilled"
- ? neteaseResult.value.songs
- : []),
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
+ const albums = [
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
+ ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
+ ];
+ const playlists = [
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
+ ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
+ ];
- res.json({ songs });
+ res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
index a9af600..4f0930b 100644
--- a/src/web/api/player.ts
+++ b/src/web/api/player.ts
@@ -313,6 +313,78 @@ export function createPlayerRouter(
}
});
+ // Play an album by ID — mirrors play-playlist but calls getAlbumSongs
+ router.post("/:botId/play-album", async (req, res) => {
+ try {
+ const bot = (req as any).bot;
+ const { albumId, platform } = req.body;
+ const provider = bot.getProviderFor(
+ platform === "bilibili" || platform === "qq" || platform === "youtube"
+ ? platform
+ : "netease"
+ );
+
+ // Stop current playback
+ bot.getPlayer().stop();
+ bot.getPlayer().resetFailures();
+
+ const songs = await provider.getAlbumSongs(albumId);
+ if (songs.length === 0) {
+ res.json({ message: "Album is empty" });
+ return;
+ }
+
+ // QQ-specific optimization: batch-resolve playable IDs to avoid
+ // wasting retries on region/copyright-restricted tracks.
+ let queueable: { id: string }[] = songs;
+ const totalCount = songs.length;
+ const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
+ if (typeof qqLike.getPlayableSongIds === "function") {
+ const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
+ if (playable !== null) {
+ queueable = songs.filter((s: { id: string }) => playable.has(s.id));
+ }
+ }
+ if (queueable.length === 0) {
+ res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
+ return;
+ }
+
+ const queue = bot.getQueueManager();
+ queue.clear();
+ for (const song of queueable) {
+ queue.add({ ...song, platform: provider.platform });
+ }
+
+ const mode = queue.getMode();
+ let first;
+ if (mode === "random" || mode === "rloop") {
+ const idx = Math.floor(Math.random() * queue.size());
+ first = queue.playAt(idx);
+ } else {
+ first = queue.play();
+ }
+
+ let started = first ? await bot.resolveAndPlay(first) : false;
+ if (first && !started) {
+ started = await bot.playNext(20);
+ }
+
+ const playing = queue.current();
+ const loadedMsg = queueable.length < totalCount
+ ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
+ : `已加载 ${queueable.length} 首`;
+ if (started && playing) {
+ res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
+ } else {
+ res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
+ }
+ } catch (err) {
+ logger.error({ err }, "play-album failed");
+ res.status(500).json({ error: (err as Error).message });
+ }
+ });
+
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
try {
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index cc060f5..d62afcd 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -22,6 +22,13 @@ const router = createRouter({
path: '/playlist/:id',
name: 'playlist',
component: () => import('../views/Playlist.vue'),
+ meta: { kind: 'playlist' },
+ },
+ {
+ path: '/album/:id',
+ name: 'album',
+ component: () => import('../views/Playlist.vue'),
+ meta: { kind: 'album' },
},
{
path: '/lyrics',
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index 9bc817d..083262c 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
+ async playAlbum(albumId: string, platform = 'netease') {
+ if (!this.activeBotId) return;
+ const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
+ if (res.data?.message) {
+ this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
+ }
+ this._setTiming(this.activeBotId, { serverElapsed: 0 });
+ this._syncAfterAction();
+ },
+
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value
diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue
index d5c9f8d..d00101d 100644
--- a/web/src/views/Playlist.vue
+++ b/web/src/views/Playlist.vue
@@ -38,7 +38,7 @@
</div>
</template>
- <div v-else class="loading">歌单不存在或加载失败</div>
+ <div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
</div>
</template>
@@ -64,6 +64,8 @@ interface PlaylistDetail {
songCount: number;
}
+const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
+
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
@@ -71,20 +73,32 @@ const loading = ref(true);
async function playAll() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
- await store.playPlaylist(id, platform);
+ if (kind === 'album') {
+ await store.playAlbum(id, platform);
+ } else {
+ await store.playPlaylist(id, platform);
+ }
}
onMounted(async () => {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
+ const detailUrl = kind === 'album'
+ ? `/api/music/album/${id}/detail`
+ : `/api/music/playlist/${id}/detail`;
+ const songsUrl = kind === 'album'
+ ? `/api/music/album/${id}`
+ : `/api/music/playlist/${id}`;
+
// allSettled, not Promise.all — if detail 404s but songs is fine
// (e.g., a QQ playlist whose detail endpoint flaked but the song
// list resolved), we still want to show the songs rather than
- // the "歌单不存在" empty state.
+ // the "不存在" empty state. For albums, detail always 404s — that
+ // is intentional; the fallback stub below handles it.
const [detailRes, songsRes] = await Promise.allSettled([
- axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
- axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
+ axios.get(detailUrl, { params: { platform } }),
+ axios.get(songsUrl, { params: { platform } }),
]);
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
@@ -96,7 +110,7 @@ onMounted(async () => {
// Fall back to a stub built from the route + first song's cover.
playlist.value = {
id,
- name: '歌单',
+ name: kind === 'album' ? '专辑' : '歌单',
description: '',
coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length,
@@ -104,7 +118,7 @@ onMounted(async () => {
} else {
playlist.value = null;
if (detailRes.status === 'rejected') {
- console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
+ console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
}
}
songs.value = songList;
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue
index 0536e36..8822277 100644
--- a/web/src/views/Search.vue
+++ b/web/src/views/Search.vue
@@ -20,22 +20,54 @@
<div v-if="loading" class="loading">搜索中...</div>
- <div v-else-if="results.length > 0" class="results">
- <SongCard
- v-for="(song, i) in results"
- :key="`${song.platform}-${song.id}`"
- :song="song"
- :index="i + 1"
- :active="store.currentSong?.id === song.id"
- @play="store.playSong(song)"
- @playNext="store.playNextSong(song)"
- @add="store.addSong(song)"
- />
- </div>
+ <template v-else-if="songs.length || albums.length || playlists.length">
+ <section v-if="albums.length" class="result-section">
+ <h2 class="section-title">专辑</h2>
+ <div class="card-grid">
+ <router-link
+ v-for="al in albums"
+ :key="`${al.platform}-${al.id}`"
+ :to="`/album/${al.id}?platform=${al.platform}`"
+ class="card hover-scale"
+ >
+ <CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
+ <div class="card-name">{{ al.name }}</div>
+ <div class="card-sub">{{ al.artist }}</div>
+ </router-link>
+ </div>
+ </section>
+
+ <section v-if="playlists.length" class="result-section">
+ <h2 class="section-title">歌单</h2>
+ <div class="card-grid">
+ <router-link
+ v-for="pl in playlists"
+ :key="`${pl.platform}-${pl.id}`"
+ :to="`/playlist/${pl.id}?platform=${pl.platform}`"
+ class="card hover-scale"
+ >
+ <CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
+ <div class="card-name">{{ pl.name }}</div>
+ </router-link>
+ </div>
+ </section>
+
+ <section v-if="songs.length" class="result-section">
+ <h2 class="section-title">单曲</h2>
+ <SongCard
+ v-for="(song, i) in songs"
+ :key="`${song.platform}-${song.id}`"
+ :song="song"
+ :index="i + 1"
+ :active="store.currentSong?.id === song.id"
+ @play="store.playSong(song)"
+ @playNext="store.playNextSong(song)"
+ @add="store.addSong(song)"
+ />
+ </section>
+ </template>
- <div v-else-if="searched" class="empty">
- 未找到相关结果
- </div>
+ <div v-else-if="searched" class="empty">未找到相关结果</div>
</div>
</template>
@@ -45,15 +77,21 @@ import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
+import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
+import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const query = ref((route.query.q as string) || '');
-import { Song } from '../stores/player.js';
-const results = ref<Song[]>([]);
+interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
+interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
+
+const songs = ref<Song[]>([]);
+const albums = ref<Album[]>([]);
+const playlists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
@@ -62,12 +100,12 @@ async function doSearch() {
loading.value = true;
searched.value = true;
try {
- const res = await axios.get('/api/music/search/all', {
- params: { q: query.value },
- });
- results.value = res.data.songs;
+ const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
+ songs.value = res.data.songs ?? [];
+ albums.value = res.data.albums ?? [];
+ playlists.value = res.data.playlists ?? [];
} catch {
- results.value = [];
+ songs.value = []; albums.value = []; playlists.value = [];
} finally {
loading.value = false;
}
@@ -141,4 +179,23 @@ onMounted(() => {
flex-direction: column;
gap: 2px;
}
+
+.result-section {
+ margin-bottom: 32px;
+ .section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
+}
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 16px;
+}
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ text-decoration: none;
+ color: inherit;
+ .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
+ .card-sub { font-size: 12px; opacity: 0.6; }
+}
</style>
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Smoke test for issue #51 — run AFTER you start the bot from temp/preview-merge
# (or from main once both PRs are merged).
#
# Usage: ./scripts/smoke_issue51.sh [HOST]
# Default HOST is http://127.0.0.1:3000
set -e
HOST="${1:-http://127.0.0.1:3000}"
PASS=0
FAIL=0
note() { echo -e "\n=== $* ==="; }
ok() { echo " [PASS] $*"; PASS=$((PASS+1)); }
bad() { echo " [FAIL] $*"; FAIL=$((FAIL+1)); }
# ---- Album search ----------------------------------------------------------
note "1. /api/music/search/all returns {songs,albums,playlists}"
RES=$(curl.exe -s "$HOST/api/music/search/all?q=%E5%91%A8%E6%9D%B0%E4%BC%A6") # 周杰伦
KEYS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(','.join(sorted(d.keys())))")
if [ "$KEYS" = "albums,playlists,songs" ]; then ok "keys = $KEYS"; else bad "keys = $KEYS (expected albums,playlists,songs)"; fi
NA=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('albums',[])))")
NS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))")
NP=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('playlists',[])))")
echo " songs=$NS, albums=$NA, playlists=$NP"
if [ "$NA" -gt 0 ]; then ok "albums populated"; else bad "albums empty (expected >0 for 周杰伦)"; fi
if [ "$NS" -gt 0 ]; then ok "songs populated"; fi
# ---- Album detail playback path -------------------------------------------
note "2. /api/music/album/:id returns songs"
if [ "$NA" -gt 0 ]; then
ALBUM_ID=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['id'])")
PLATFORM=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['platform'])")
echo " testing album id=$ALBUM_ID platform=$PLATFORM"
ASONGS=$(curl.exe -s "$HOST/api/music/album/$ALBUM_ID?platform=$PLATFORM" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))" 2>/dev/null || echo 0)
if [ "$ASONGS" -gt 0 ]; then ok "album returned $ASONGS songs"; else bad "album endpoint returned 0 songs"; fi
else
echo " (skipped — no albums to test)"
fi
# ---- Avatar API ------------------------------------------------------------
note "3. avatar GET 404 on bot with no avatar"
BOT_ID=$(curl.exe -s "$HOST/api/bot" | python3 -c "import json,sys;d=json.load(sys.stdin);bots=d.get('bots',[]);print(bots[0]['id'] if bots else '')")
if [ -z "$BOT_ID" ]; then bad "no bot found — create a bot first"; exit 1; fi
echo " using bot $BOT_ID"
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "404" ] || [ "$CODE" = "200" ]; then ok "GET initial state = $CODE"; else bad "unexpected GET status $CODE"; fi
note "4. avatar PUT 200 + GET 200 round-trip"
# 1×1 transparent PNG (67 bytes)
TINY_PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
PUT_RES=$(curl.exe -s -X PUT "$HOST/api/bot/$BOT_ID/avatar" -H "Content-Type: application/json" \
-d "{\"dataUrl\":\"data:image/png;base64,$TINY_PNG_B64\"}")
echo " PUT response: $PUT_RES"
GOT_PATH=$(echo "$PUT_RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('path',''))" 2>/dev/null || echo "")
if [ "$GOT_PATH" = "$BOT_ID.png" ]; then ok "PUT returned path=$GOT_PATH"; else bad "PUT path = $GOT_PATH (expected $BOT_ID.png)"; fi
curl.exe -s -o /tmp/avatar_check.png -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
SIZE=$(wc -c < /tmp/avatar_check.png)
if [ "$CODE" = "200" ] && [ "$SIZE" -gt 60 ]; then ok "GET returned 200, $SIZE bytes"; else bad "GET status=$CODE size=$SIZE"; fi
note "5. avatar DELETE 204 + GET 404"
curl.exe -s -X DELETE "$HOST/api/bot/$BOT_ID/avatar" -o /dev/null -w "%{http_code}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "204" ]; then ok "DELETE returned 204"; else bad "DELETE status = $CODE"; fi
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "404" ]; then ok "GET after DELETE returned 404"; else bad "GET after DELETE = $CODE"; fi
note "6. avatar PUT rejects oversize (>200KB)"
BIG_B64=$(node -e "console.log(Buffer.alloc(210*1024,7).toString('base64'))")
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/png;base64,$BIG_B64\"}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "413" ]; then ok "oversize rejected with 413"; else bad "oversize status = $CODE (expected 413)"; fi
note "7. avatar PUT rejects bad MIME (image/gif)"
GIF_B64="R0lGODlhAQABAAAAACw=" # tiny gif
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/gif;base64,$GIF_B64\"}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "400" ]; then ok "bad MIME rejected with 400"; else bad "bad MIME status = $CODE (expected 400)"; fi
# ---------------------------------------------------------------------------
echo ""
echo "============================================="
echo "SMOKE RESULT: $PASS passed, $FAIL failed"
echo "============================================="
[ "$FAIL" -eq 0 ]
+7 -1
View File
@@ -295,7 +295,13 @@ export class BotInstance extends EventEmitter {
const relPath = this.database.getCustomAvatarPath(this.id); const relPath = this.database.getCustomAvatarPath(this.id);
if (relPath) { if (relPath) {
const buf = this.avatarStore.read(relPath); const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf); // loadCustomAvatar, NOT setCustomAvatar (#148): we are still in the
// constructor, so tsClient has not connected. setCustomAvatar would
// start a file transfer right here and fail. profileManager.onConnect()
// uploads it for real once the handshake completes.
// `length > 0` because avatarStore.write is delete-then-write, so a
// crash mid-write leaves a 0-byte file that is truthy as a Buffer.
if (buf && buf.length > 0) this.profileManager.loadCustomAvatar(buf);
} }
} catch (err) { } catch (err) {
this.logger.warn({ err }, "Failed to load custom avatar — skipping"); this.logger.warn({ err }, "Failed to load custom avatar — skipping");
+57
View File
@@ -145,3 +145,60 @@ describe("BotProfileManager custom avatar precedence", () => {
expect(ts.clearCalls).toBe(0); expect(ts.clearCalls).toBe(0);
}); });
}); });
// #148: the persisted avatar is loaded in the BotInstance constructor, before
// tsClient.connect() has run. Loading it must not touch the wire at all.
describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("does not upload or clear anything when called before connect", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
expect(ts.fileTransferInitUpload).not.toHaveBeenCalled();
});
it("the loaded avatar is uploaded once onConnect fires", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
await flush();
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([7, 7, 7]))).toBe(true);
});
it("survives a reconnect: onConnect re-applies the loaded avatar every time", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([8]));
pm.onConnect();
await flush();
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(2);
});
it("loading null leaves the wire untouched and onConnect stays quiet", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(null);
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar still uploads immediately after connect (post-connect edit unchanged)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([1]));
pm.onConnect();
await flush();
ts.uploadCalls.length = 0;
pm.setCustomAvatar(Buffer.from([2, 2]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
});
});
+14
View File
@@ -65,6 +65,20 @@ export class BotProfileManager {
// --- Public API --- // --- Public API ---
/**
* Store a persisted custom avatar WITHOUT touching TeamSpeak (#148).
*
* Used during BotInstance construction, when the TS connection does not
* exist yet: setCustomAvatar would immediately fire the three-step file
* transfer (fileTransferInitUpload → uploadFileData → clientupdate) against
* a client that has not connected, so the upload always failed and the
* saved avatar never appeared. onConnect() re-applies this.customAvatar
* once the handshake completes, so loading it silently here loses nothing.
*/
loadCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
/** /**
* Set/clear the persistent idle avatar. Pass null to remove. * Set/clear the persistent idle avatar. Pass null to remove.
* *
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// unlinkSync/rmdirSync are NOT mocked below, so the test's own fixture
// teardown is unaffected by the simulated lock on *.mp4.
import { mkdtempSync, statSync, existsSync, readFileSync, unlinkSync, readdirSync, rmdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* #149: when the audio track is extracted successfully but the source video
* cannot be deleted (Windows keeps files locked briefly — rmSync with
* force:true still throws EBUSY/EPERM), the record must fall back to the
* ORIGINAL container completely: both the path AND the recorded size.
*
* Committing the size before the delete succeeded would leave the record
* claiming the small extracted size while still holding the whole video, so
* totalBytes() under-counts and the upload directory grows past its quota.
*
* This lives in its own file because it partially mocks node:fs, which would
* otherwise leak into every other test in local.test.ts.
*/
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
default: actual,
rmSync: (path: string, opts?: object) => {
// Simulate the lock on the source video only; every other delete
// (the discarded .mka, temp dirs, the reject path) behaves normally.
if (typeof path === "string" && path.endsWith(".mp4")) {
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
}
return actual.rmSync(path, opts as never);
},
};
});
const { LocalMusicProvider } = await import("./local.js");
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-extract-fallback-")); });
afterEach(() => {
// Recursive teardown without rmSync (mocked above for *.mp4).
for (const f of readdirSync(dir)) {
try { unlinkSync(join(dir, f)); } catch { /* best effort */ }
}
try { rmdirSync(dir); } catch { /* best effort */ }
});
describe("LocalMusicProvider: source video cannot be deleted after extraction (#149)", () => {
it.runIf(have)("keeps the original container AND its real size, not the extracted size", async () => {
const src = join(dir, "fixture.mp4");
const r = spawnSync(ffmpeg!, [
"-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=s=320x240:r=25:d=3",
"-f", "lavfi", "-i", "sine=f=440:d=3",
"-c:v", "libx264", "-b:v", "800k", "-c:a", "aac", "-shortest", src,
], { stdio: "ignore" });
expect(r.status).toBe(0);
const bytes = readFileSync(src);
unlinkSync(src); // uploadAudio writes its own copy under a uuid name
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: bytes, originalName: "fixture.mp4", mimeType: "video/mp4",
});
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// Fell back to the original container — the extract was discarded.
expect(resolved!.url.endsWith(".mp4")).toBe(true);
expect(existsSync(resolved!.url)).toBe(true);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false);
const onDisk = statSync(resolved!.url).size;
expect(onDisk).toBe(bytes.length);
// The RECORDED size drives the quota (totalBytes()), so it must describe
// the file actually retained. It is not exposed through search()/toSong,
// but it is persisted to index.json — read it back from there.
const record = (JSON.parse(readFileSync(join(dir, "index.json"), "utf8")) as Array<{
id: string; size: number; filePath: string;
}>).find((r) => r.id === song.id);
expect(record).toBeDefined();
expect(record!.filePath.endsWith(".mp4")).toBe(true);
// Before the fix this was the (much smaller) .mka size while the whole
// .mp4 stayed on disk, so the quota under-counted the retained bytes.
expect(record!.size).toBe(bytes.length);
// Sanity: the extract really is much smaller, so a wrong commit order
// would have been clearly observable rather than a rounding error.
expect(onDisk).toBeGreaterThan(50_000);
}, 60000);
});
+257 -2
View File
@@ -1,8 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { buildFfmpegArgs } from "../audio/player.js";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { LocalMusicProvider } from "./local.js"; import { LocalMusicProvider, parseMediaProbe } from "./local.js";
let dir: string; let dir: string;
@@ -149,6 +152,115 @@ describe("LocalMusicProvider upload validation", () => {
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }), p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow(); ).rejects.toThrow();
}); });
// #149: video containers are accepted; only their audio track is kept.
it("still rejects a non-media extension after video was allowed", async () => {
const p = new LocalMusicProvider(dir);
for (const name of ["evil.exe", "evil.html", "evil.mp4.txt", "notes.pdf"]) {
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: name, mimeType: "video/mp4" }),
).rejects.toThrow();
}
});
it("accepts every supported video extension at the extension gate", async () => {
const p = new LocalMusicProvider(dir);
// Junk content: ffmpeg cannot open it, so it is "unrecognised" rather than
// "no audio track" and must be accepted exactly like a truncated .mp3
// always has been. The extension allowlist is what is under test here.
// .m4v is excluded on purpose — see the next test.
for (const ext of [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".ts", ".m2ts", ".ogv"]) {
const song = await p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: `clip${ext}`,
mimeType: "video/mp4",
});
expect(song.platform).toBe("local");
expect(song.name).toBe("clip");
}
});
it("refuses a .m4v raw video elementary stream, which by definition has no audio", async () => {
// .m4v is not a container — ffmpeg's rawvideo demuxer opens arbitrary
// bytes as an MPEG-4 video elementary stream, so it IS recognised and
// genuinely carries no audio track. Refusing it is the correct outcome,
// and it is the one case that distinguishes `recognized` from `probed`.
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: "clip.m4v",
mimeType: "video/x-m4v",
}),
).rejects.toThrow(/音轨/);
});
it("the error message names both audio and video formats", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: "a.exe" }),
).rejects.toThrow(/视频/);
});
});
describe("parseMediaProbe (#149)", () => {
const mp4Banner = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':
Duration: 00:03:27.15, start: 0.000000, bitrate: 1105 kb/s
Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1280x720, 30 fps
Stream #0:1[0x2](und): Audio: aac (LC), 48000 Hz, stereo, fltp, 192 kb/s`;
it("reads duration and detects the audio stream in a video container", () => {
const r = parseMediaProbe(mp4Banner);
expect(r.durationSeconds).toBe(3 * 60 + 27);
expect(r.hasAudio).toBe(true);
});
it("reports hasAudio false for a video with only a video stream", () => {
const silent = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'silent.mp4':
Duration: 00:00:02.00, start: 0.000000, bitrate: 29 kb/s
Stream #0:0[0x1](und): Video: h264 (High 4:4:4 Predictive), yuv444p, 160x120, 10 fps`;
const r = parseMediaProbe(silent);
expect(r.durationSeconds).toBe(2);
expect(r.hasAudio).toBe(false);
});
it("detects a plain audio file", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'a.mp3':
Duration: 00:00:30.02, start: 0.000000, bitrate: 128 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s`);
expect(r.durationSeconds).toBe(30);
expect(r.hasAudio).toBe(true);
});
it("does not mistake an attached cover image for an audio stream", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'cover.mp3':
Duration: 00:00:10.00, start: 0.000000, bitrate: 130 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
Stream #0:1: Video: mjpeg (Baseline), yuvj420p(pc), 100x100 [attached pic]`);
expect(r.hasAudio).toBe(true);
});
it("returns zeros on unparseable output rather than throwing", () => {
const r = parseMediaProbe("ffmpeg: command exploded");
expect(r.durationSeconds).toBe(0);
expect(r.hasAudio).toBe(false);
expect(r.recognized).toBe(false);
});
// The distinction that decides whether an upload is refused: ffmpeg opened
// the file and found no audio (refuse) vs ffmpeg could not open it at all
// (accept, as it always has for truncated audio).
it("marks a readable container recognized and unreadable bytes not", () => {
expect(parseMediaProbe(mp4Banner).recognized).toBe(true);
expect(parseMediaProbe(`[mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found
[in#0 @ 0x2] Error opening input: Invalid data found when processing input
Error opening input file junk.mp4.`).recognized).toBe(false);
});
it("rounds fractional durations", () => {
expect(parseMediaProbe("Duration: 00:00:03.60,").durationSeconds).toBe(4);
expect(parseMediaProbe("Duration: 01:02:03.10,").durationSeconds).toBe(3723);
});
}); });
describe("LocalMusicProvider quota", () => { describe("LocalMusicProvider quota", () => {
@@ -233,3 +345,146 @@ describe("LocalMusicProvider filename handling", () => {
expect(await p.getSongUrl(song.id)).not.toBeNull(); expect(await p.getSongUrl(song.id)).not.toBeNull();
}); });
}); });
// #149 end-to-end: build real containers with the bundled ffmpeg and push
// them through the actual upload path. Skipped automatically if the binary is
// unavailable, so the suite still runs on a machine without it.
describe("LocalMusicProvider video upload, end to end (#149)", () => {
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
/** Render a real container into the temp dir and return its bytes. */
function render(name: string, args: string[]): Buffer {
const out = join(dir, name);
const r = spawnSync(ffmpeg!, ["-y", "-hide_banner", "-loglevel", "error", ...args, out], {
stdio: "ignore",
});
if (r.status !== 0) throw new Error(`fixture render failed: ${name}`);
const buf = readFileSync(out);
rmSync(out, { force: true }); // upload writes its own copy
return buf;
}
const withAudio = (dur: number, vcodec: string, acodec: string) => [
"-f", "lavfi", "-i", `testsrc=s=160x120:r=10:d=${dur}`,
"-f", "lavfi", "-i", `sine=f=440:d=${dur}`,
"-c:v", vcodec, "-c:a", acodec, "-shortest",
];
it.runIf(have)("accepts an mp4, reads its duration, and keeps only the audio", async () => {
const p = new LocalMusicProvider(dir);
const mp4 = render("src.mp4", withAudio(3, "libx264", "aac"));
const song = await p.uploadAudio({
buffer: mp4, originalName: "My Clip.mp4", mimeType: "video/mp4",
});
expect(song.name).toBe("My Clip");
expect(song.platform).toBe("local");
expect(song.duration).toBe(3);
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// The video container is gone; what remains is the extracted audio track.
// AAC (what libx264+aac mp4s carry) goes to .m4a so the encoder-priming
// edit list survives — see extractedAudioExt.
expect(resolved!.url.endsWith(".m4a")).toBe(true);
expect(existsSync(join(dir, `${song.id}.mp4`))).toBe(false);
expect(existsSync(resolved!.url)).toBe(true);
expect(statSync(resolved!.url).size).toBeGreaterThan(0);
expect(statSync(resolved!.url).size).toBeLessThan(mp4.length);
}, 60000);
it.runIf(have)("extracted audio is still decodable by the player's ffmpeg args", async () => {
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: render("src2.mp4", withAudio(2, "libx264", "aac")),
originalName: "clip.mp4",
mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const decoded = spawnSync(
ffmpeg!,
[...buildFfmpegArgs(url, 0).slice(0, -1), "-"],
{ maxBuffer: 64 * 1024 * 1024 },
);
expect(decoded.status).toBe(0);
// 2s of 48 kHz stereo s16le ≈ 384000 bytes; allow codec priming slack.
expect(decoded.stdout.length).toBeGreaterThan(300000);
}, 60000);
it.runIf(have)("aac extraction decodes bit-for-bit identically to the audio inside the video", async () => {
// The strongest statement of "lossless": decode the audio track straight
// out of the source mp4, decode the stored extract, compare the PCM.
// A Matroska remux would NOT pass this — it loses the MP4 edit list that
// discards AAC encoder priming, so it decodes ~23 ms longer.
const p = new LocalMusicProvider(dir);
const bytes = render("bitexact.mp4", withAudio(4, "libx264", "aac"));
const sourceCopy = join(dir, "source-kept.mp4");
writeFileSync(sourceCopy, bytes);
const song = await p.uploadAudio({
buffer: bytes, originalName: "bitexact.mp4", mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const toPcm = (input: string, pre: string[] = []) => spawnSync(
ffmpeg!,
["-hide_banner", "-loglevel", "error", "-i", input, ...pre,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"],
{ maxBuffer: 128 * 1024 * 1024 },
);
const fromVideo = toPcm(sourceCopy, ["-vn", "-map", "0:a:0"]);
const fromExtract = toPcm(url);
expect(fromVideo.status).toBe(0);
expect(fromExtract.status).toBe(0);
expect(fromExtract.stdout.length).toBe(fromVideo.stdout.length);
expect(fromExtract.stdout.equals(fromVideo.stdout)).toBe(true);
}, 90000);
it.runIf(have)("refuses a video that genuinely has no audio track", async () => {
const p = new LocalMusicProvider(dir);
const silent = render("silent.mp4", [
"-f", "lavfi", "-i", "testsrc=s=160x120:r=10:d=2", "-an",
]);
await expect(
p.uploadAudio({ buffer: silent, originalName: "silent.mp4", mimeType: "video/mp4" }),
).rejects.toThrow(/音轨/);
// The rejected upload must not leave its bytes behind.
expect(readdirSync(dir).filter((f) => f.endsWith(".mp4"))).toEqual([]);
}, 60000);
it.runIf(have)("extracts losslessly from avi/mkv/flv too, not just mp4", async () => {
const p = new LocalMusicProvider(dir);
const cases: Array<[string, string[]]> = [
["a.avi", withAudio(2, "mpeg4", "libmp3lame")],
["a.mkv", withAudio(2, "libx264", "libopus")],
["a.flv", withAudio(2, "flv", "libmp3lame")],
];
for (const [name, args] of cases) {
const song = await p.uploadAudio({
buffer: render(`src-${name}`, args), originalName: name, mimeType: "video/x-msvideo",
});
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mka")).toBe(true);
expect(statSync(url).size).toBeGreaterThan(0);
}
}, 120000);
it.runIf(have)("a plain audio upload is untouched — no extraction, original extension kept", async () => {
const p = new LocalMusicProvider(dir);
const mp3 = render("src.mp3", ["-f", "lavfi", "-i", "sine=f=440:d=2", "-c:a", "libmp3lame"]);
const song = await p.uploadAudio({ buffer: mp3, originalName: "tune.mp3", mimeType: "audio/mpeg" });
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mp3")).toBe(true);
expect(statSync(url).size).toBe(mp3.length); // byte-identical, not remuxed
}, 60000);
});
+224 -25
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
@@ -34,6 +34,56 @@ const AUDIO_EXTENSIONS = new Set([
".ape", ".ape",
]); ]);
/** Video containers accepted for upload (#149). Only the audio track is ever
* used — the bot has no video output. Playback would work straight from the
* container (ffmpeg selects the audio stream), but we extract the audio on
* upload so a 200 MB clip does not sit on disk for a 3 MB song; see
* extractAudioTrack. `.webm` is deliberately absent: it is already in
* AUDIO_EXTENSIONS and both audio-only and video .webm are handled there. */
const VIDEO_EXTENSIONS = new Set([
".mp4",
".mov",
".avi",
".mkv",
".flv",
".wmv",
".m4v",
".mpg",
".mpeg",
".3gp",
".ts",
".m2ts",
".ogv",
]);
/** Fallback container for an extracted audio track. Matroska takes
* essentially any audio codec, so `-c:a copy` works without knowing what the
* source used — no re-encode, no codec/extension table. */
const EXTRACTED_AUDIO_EXT = ".mka";
/**
* Container to remux an extracted track into, chosen by its codec.
*
* AAC gets .m4a rather than the Matroska fallback. MP4 stores the AAC encoder
* priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list,
* and that edit list does NOT survive into Matroska — so an aac→.mka remux
* decodes ~23 ms longer than the source, with the priming samples audible at
* the head instead of discarded. Measured: −66 dBFS, i.e. inaudible, but the
* track is then fractionally out of step with its own reported duration for
* no reason. Copying aac into .m4a keeps the edit list and decodes
* byte-for-byte identical to the audio inside the original video.
*
* AAC is worth special-casing because it is what mp4 / mov / m4v — the
* formats people actually upload — almost always carry.
*/
function extractedAudioExt(codec: string | null): string {
return codec === "aac" ? ".m4a" : EXTRACTED_AUDIO_EXT;
}
function isSupportedUploadExt(ext: string): boolean {
return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext);
}
const DEFAULT_MAX_FILES = 200; const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
@@ -71,37 +121,128 @@ function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频"; return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
} }
async function probeDurationSeconds(filePath: string): Promise<number> { export interface MediaProbe {
/** Rounded seconds, 0 when the probe failed or the container has no duration. */
durationSeconds: number;
/** True when ffmpeg reported at least one audio stream. Only meaningful
* together with `recognized` — see the comment there. */
hasAudio: boolean;
/** Lowercased codec name of the first audio stream ("aac", "mp3", "opus",
* …), or null when there is none. Picks the remux container. */
audioCodec: string | null;
/**
* True when ffmpeg actually opened the container and printed its
* `Input #0, <format>, from '...'` header.
*
* This is what separates "ffmpeg looked inside and there is genuinely no
* audio track" from "ffmpeg could not make sense of these bytes at all".
* Both produce hasAudio === false, but only the first is a file we should
* refuse. Unreadable bytes have always been accepted here (a truncated mp3
* uploads fine and simply reports duration 0), and that stays true.
*/
recognized: boolean;
/** False when ffmpeg could not be run or timed out, so nothing else in this
* object is meaningful and the caller must not reject the file on it. */
probed: boolean;
}
/** Parse `Duration: HH:MM:SS.ss`, the `Input #0,` header and
* `Stream #0:N...: Audio:` out of the banner ffmpeg prints on stderr when
* asked to open a file with no output. */
export function parseMediaProbe(stderr: string): Omit<MediaProbe, "probed"> {
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
let durationSeconds = 0;
if (match) {
const total = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
durationSeconds = Number.isFinite(total) ? Math.round(total) : 0;
}
// e.g. " Stream #0:1[0x2](und): Audio: aac (LC) ..." — the stream index and
// the bracketed id/language vary, so match on the "Audio:" tag itself. An
// embedded cover image is a separate "Video: mjpeg ... [attached pic]" line
// and never matches this.
const audioMatch = stderr.match(/Stream #\d+:\d+[^\n]*:\s*Audio:\s*([A-Za-z0-9_]+)/);
const hasAudio = audioMatch !== null;
const audioCodec = audioMatch ? audioMatch[1].toLowerCase() : null;
// "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':" — absent entirely
// when ffmpeg bails with "Error opening input: Invalid data found ...".
const recognized = /^Input #\d+,/m.test(stderr);
return { durationSeconds, hasAudio, audioCodec, recognized };
}
async function probeMedia(filePath: string): Promise<MediaProbe> {
return new Promise((resolve) => { return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], { const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
}); });
let stderr = ""; let stderr = "";
let settled = false;
const done = (probe: MediaProbe) => {
if (settled) return;
settled = true;
resolve(probe);
};
// Video containers are much larger than the audio files this used to see,
// and the probe only reads headers — but a network/USB path can still be
// slow, so allow more than the old 5s before giving up.
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL"); ffmpeg.kill("SIGKILL");
resolve(0); done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
}, 5000); }, 20000);
ffmpeg.stderr.on("data", (chunk) => { ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8"); stderr += chunk.toString("utf8");
}); });
ffmpeg.on("error", () => { ffmpeg.on("error", () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(0); done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
}); });
ffmpeg.on("close", () => { ffmpeg.on("close", () => {
clearTimeout(timeout); clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/); done({ ...parseMediaProbe(stderr), probed: true });
if (!match) { });
resolve(0); });
return; }
/**
* Remux the first audio stream of `source` into `target` (#149).
*
* `-c:a copy` — the audio is moved bit-for-bit into a Matroska audio
* container, so this is fast, lossless, and codec-agnostic. Nothing is
* re-encoded, so a 200 MB .mp4 becomes a few MB .mka with the original audio
* intact. Video, subtitle and data streams are dropped.
*
* Returns true only if ffmpeg exited 0 AND produced a non-empty file, so a
* partial/zero-byte result can never be mistaken for a successful extraction.
* Callers fall back to keeping the original container, which plays fine.
*/
async function extractAudioTrack(source: string, target: string): Promise<boolean> {
const ok = await new Promise<boolean>((resolve) => {
const ffmpeg = spawn(
ffmpegPath || "ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-i", source,
"-vn", "-sn", "-dn", "-map", "0:a:0", "-c:a", "copy", target],
{ stdio: ["ignore", "ignore", "ignore"] },
);
let settled = false;
const done = (v: boolean) => {
if (settled) return;
settled = true;
resolve(v);
};
// Remuxing is I/O bound, but a multi-GB input on a slow disk still takes
// a while. Cap it so a pathological file cannot wedge the upload request.
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
done(false);
}, 120000);
ffmpeg.on("error", () => { clearTimeout(timeout); done(false); });
ffmpeg.on("close", (code) => { clearTimeout(timeout); done(code === 0); });
});
if (!ok) return false;
try {
return statSync(target).size > 0;
} catch {
return false;
} }
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
});
});
} }
export class LocalMusicProvider implements MusicProvider { export class LocalMusicProvider implements MusicProvider {
@@ -171,33 +312,91 @@ export class LocalMusicProvider implements MusicProvider {
const ext = path.extname(originalName).toLowerCase(); const ext = path.extname(originalName).toLowerCase();
// Validate by the (sanitised) file extension only — never trust the // Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension // client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an // is one of the known audio/video types, so a spoofed header cannot
// arbitrary-extension blob on disk. // persist an arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) { if (!isSupportedUploadExt(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等"); throw new Error(
"只支持常见音频文件(mp3、flac、wav、m4a、ogg、opus、aac、webm 等)" +
"和视频文件(mp4、mov、avi、mkv、flv、wmv 等,仅取其中的音轨播放)",
);
} }
if (!input.buffer || input.buffer.length === 0) { if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空"); throw new Error("上传文件为空");
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const storedName = `${id}${ext}`; const isVideo = VIDEO_EXTENSIONS.has(ext);
const filePath = path.join(this.uploadDir, storedName); let filePath = path.join(this.uploadDir, `${id}${ext}`);
writeFileSync(filePath, input.buffer); writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath); let probe: MediaProbe;
try {
probe = await probeMedia(filePath);
} catch {
probe = { durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false };
}
// Reject a video with no audio track up front (#149). Left to playback it
// would produce a silent, zero-byte stream that just looks like a broken
// song. Require `recognized` as well as `probed`: bytes ffmpeg cannot open
// at all report hasAudio false for a different reason, and those have
// always been accepted (a truncated upload lands with duration 0) — this
// change must not start rejecting them.
if (isVideo && probe.probed && probe.recognized && !probe.hasAudio) {
rmSync(filePath, { force: true });
throw new Error("这个视频里没有音轨,无法播放");
}
let size = input.buffer.length;
if (isVideo) {
// Keep only the audio. The video bytes are dead weight against the
// upload-directory quota and would never be used.
// Preferred container first; if that remux fails (a codec the container
// will not take), retry into Matroska, which takes almost anything.
const preferredExt = extractedAudioExt(probe.audioCodec);
let extracted = path.join(this.uploadDir, `${id}${preferredExt}`);
let ok = await extractAudioTrack(filePath, extracted);
if (!ok && preferredExt !== EXTRACTED_AUDIO_EXT) {
rmSync(extracted, { force: true });
extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`);
ok = await extractAudioTrack(filePath, extracted);
}
if (ok) {
try {
// Commit filePath and size TOGETHER, and only after the source is
// actually gone. rmSync(force) still throws EBUSY/EPERM on Windows,
// and assigning size first would leave the record claiming the
// small extracted size while still pointing at the whole video —
// which makes totalBytes() under-count and lets the upload
// directory grow past its quota.
const extractedSize = statSync(extracted).size;
rmSync(filePath, { force: true });
filePath = extracted;
size = extractedSize;
} catch {
// Could not stat/remove (Windows lock) — keep playing the original
// container and drop the half-finished extract.
rmSync(extracted, { force: true });
}
} else {
// Extraction failed (exotic codec Matroska won't take, timeout, …).
// The original container still plays: ffmpeg picks its audio stream.
rmSync(extracted, { force: true });
}
}
const song: LocalSongRecord = { const song: LocalSongRecord = {
id, id,
name: titleFromFileName(originalName), name: titleFromFileName(originalName),
artist: "本地上传", artist: "本地上传",
album: "本地音乐", album: "本地音乐",
duration, duration: probe.durationSeconds,
coverUrl: "", coverUrl: "",
platform: "local", platform: "local",
filePath, filePath,
originalName, originalName,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
size: input.buffer.length, size,
mimeType: input.mimeType || "application/octet-stream", mimeType: input.mimeType || "application/octet-stream",
}; };
+144 -1
View File
@@ -14,7 +14,7 @@ import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js"; import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js"; import { createRequireAuth } from "../middleware/requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.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: [] }; const empty: SearchResult = { songs: [], albums: [], playlists: [] };
@@ -295,3 +295,146 @@ describe("music router POST /quality — persistence (#125)", () => {
expect(config.audioQuality.netease).toBe("lossless"); 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
View File
@@ -7,6 +7,56 @@ import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.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( export function createMusicRouter(
neteaseProvider: MusicProvider, neteaseProvider: MusicProvider,
qqProvider: MusicProvider, qqProvider: MusicProvider,
@@ -65,10 +115,7 @@ export function createMusicRouter(
} }
next(); next();
}, },
express.raw({ localUploadBody,
type: ["audio/*", "video/webm", "application/octet-stream"],
limit: "200mb",
}),
async (req, res) => { async (req, res) => {
try { try {
if (!localProvider) { if (!localProvider) {
+7 -1
View File
@@ -167,7 +167,13 @@ describe("session router", () => {
expect(meB.status).toBe(401); expect(meB.status).toBe(401);
expect(u.id).toBe(meA.body.id); expect(u.id).toBe(meA.body.id);
}); // 20s, not the 5s default: this case runs SIX bcryptjs rounds (one hash to
// create the user, four verifies, one hash for the new password), and
// bcryptjs is pure JS. It takes ~4.5s on an idle machine — close enough to
// the default that it tipped over whenever the full suite saturated the
// CPU, which made it look like a real intermittent failure. The work is
// genuinely slow, not hung, so the timeout is what was wrong.
}, 20000);
}); });
describe("session router — guest mode", () => { describe("session router — guest mode", () => {
+92
View File
@@ -0,0 +1,92 @@
const { Client, generateIdentity } = require('@honeybbq/teamspeak-client');
function escapeTS3(value) {
return value
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/ /g, "\\s")
.replace(/\|/g, "\\p")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
function replaceField(cmd, key, value) {
const escaped = escapeTS3(value);
const regex = new RegExp(key + "=\\S*");
if (regex.test(cmd)) return cmd.replace(regex, key + "=" + escaped);
return cmd;
}
// Version to test - passed via env
const VERSIONS = [
{
name: "6.0.0-beta2",
version: "6.0.0-beta2 [Build: 1737468425]",
platform: "Windows",
sign: "b5mySiqYAd4Lv5TZNflW+G5Gk8y7Woo9YnJfHRjmzhUyMdGfF1O7fSGJzmx2Hpe/PKaY2aDuKKD6lSxbLYlKCw==",
},
{
name: "3.?.? wildcard",
version: "3.?.? [Build: 5680278000]",
platform: "Windows",
sign: "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==",
},
{
name: "5.0.0-beta77",
version: "5.0.0-beta77 [Build: 1702382332]",
platform: "Windows",
sign: "Ee6DzP16MUXpdKWjiSY0NGb4thN22/Ks0hwNcaMrWoaadgkM6c5477X0IbGFWVjzTWfjFTEad5noYLUPDWSgCQ==",
},
{
name: "3.6.2 (corrected sign)",
version: "3.6.2 [Build: 1695203293]",
platform: "Windows",
sign: "4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw==",
},
];
const idx = parseInt(process.env.VERSION_IDX || "0");
const V = VERSIONS[idx];
const identity = generateIdentity(8);
const client = new Client(identity, "localhost:9987", "MusicBot", {
logger: {
debug: () => {},
info: (m) => console.log("[INFO]", m),
warn: (m) => console.log("[WARN]", m),
error: (m) => console.log("[ERROR]", m),
},
});
console.log("Testing version: " + V.name + " -> " + V.version);
client.connect().then(() => {
const handler = client.handler;
const origSendPacket = handler.sendPacket.bind(handler);
handler.sendPacket = (pType, data, flags) => {
if (pType === 2) {
let str = Buffer.from(data).toString("utf-8");
if (str.startsWith("clientinit ")) {
str = replaceField(str, "client_version", V.version);
str = replaceField(str, "client_platform", V.platform);
str = replaceField(str, "client_version_sign", V.sign);
console.log("[PATCHED] version=" + V.version);
origSendPacket(pType, Buffer.from(str), flags);
return;
}
}
origSendPacket(pType, data, flags);
};
return client.waitConnected();
}).then(() => {
console.log("SUCCESS! Connected with clientId = " + client.clientID());
client.disconnect();
process.exit(0);
}).catch((err) => {
console.log("ERROR:", err && err.message || err);
process.exit(1);
});
setTimeout(() => {
console.log("TIMEOUT - version rejected");
process.exit(2);
}, 10000);
+47 -10
View File
@@ -28,18 +28,18 @@
> >
<Icon icon="mdi:tray-arrow-up" class="upload-icon" /> <Icon icon="mdi:tray-arrow-up" class="upload-icon" />
<div class="upload-copy"> <div class="upload-copy">
<div class="upload-title">拖拽本地音频到这里上传</div> <div class="upload-title">拖拽本地音频 / 视频到这里上传</div>
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div> <div class="upload-subtitle">音频支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等,视频支持 mp4、mov、avi、mkv、flv、wmv 等(只取其中的音轨播放);上传后可直接播放或加入队列</div>
</div> </div>
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()"> <button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
{{ uploading ? '上传中...' : '选择音频' }} {{ uploading ? '上传中...' : '选择文件' }}
</button> </button>
<input <input
ref="fileInput" ref="fileInput"
class="file-input" class="file-input"
type="file" type="file"
multiple multiple
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape" accept="audio/*,video/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape,.mp4,.mov,.avi,.mkv,.flv,.wmv,.m4v,.mpg,.mpeg,.3gp,.ts,.m2ts,.ogv"
@change="handleFileSelect" @change="handleFileSelect"
/> />
</div> </div>
@@ -404,8 +404,17 @@ async function doSearch() {
} }
function isAudioFile(file: File): boolean { /** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name); const UPLOAD_MAX_MB = 500;
const UPLOAD_MAX_BYTES = UPLOAD_MAX_MB * 1024 * 1024;
// Video is accepted too (#149) — the server keeps only the audio track.
// Keep the extension list in sync with AUDIO_EXTENSIONS / VIDEO_EXTENSIONS in
// src/music/local.ts; the server re-validates, this just avoids a round-trip.
function isMediaFile(file: File): boolean {
return file.type.startsWith('audio/')
|| file.type.startsWith('video/')
|| /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape|mp4|mov|avi|mkv|flv|wmv|m4v|mpg|mpeg|3gp|ts|m2ts|ogv)$/i.test(file.name);
} }
async function uploadLocalFiles(fileList: File[]) { async function uploadLocalFiles(fileList: File[]) {
@@ -414,10 +423,22 @@ async function uploadLocalFiles(fileList: File[]) {
uploadMessage.value = '本地音频播放已关闭'; uploadMessage.value = '本地音频播放已关闭';
return; return;
} }
const files = fileList.filter(isAudioFile); const candidates = fileList.filter(isMediaFile);
if (candidates.length === 0) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频 / 视频文件';
return;
}
// Reject oversize files before spending minutes uploading them (#149).
// The server enforces the same cap (LOCAL_UPLOAD_LIMIT in
// src/web/api/music.ts) and answers 413 — this only saves the round-trip,
// which matters now that a single video can be hundreds of megabytes.
const files = candidates.filter((f) => f.size <= UPLOAD_MAX_BYTES);
const oversize = candidates.filter((f) => f.size > UPLOAD_MAX_BYTES);
if (files.length === 0) { if (files.length === 0) {
uploadMessageType.value = 'error'; uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频文件'; uploadMessage.value = `文件太大,单个文件上限 ${UPLOAD_MAX_MB} MB:${oversize[0].name}`;
return; return;
} }
@@ -426,8 +447,19 @@ async function uploadLocalFiles(fileList: File[]) {
uploadMessage.value = `正在上传 ${files.length} 个文件...`; uploadMessage.value = `正在上传 ${files.length} 个文件...`;
const uploaded: Song[] = []; const uploaded: Song[] = [];
const failed: string[] = []; const failed: string[] = oversize.map((f) => `${f.name}: 超过 ${UPLOAD_MAX_MB} MB 上限`);
for (const file of files) { for (const [i, file] of files.entries()) {
// Videos are orders of magnitude bigger than the audio files this used to
// handle (#149), so a silent "正在上传..." can sit there for minutes and
// look hung. Report per-file percentage while the bytes are in flight, and
// switch to a processing note once the server takes over (it still has to
// probe the file and remux the audio track out).
const label = files.length > 1 ? `(${i + 1}/${files.length})` : '';
const setProgress = (text: string) => {
uploadMessageType.value = 'info';
uploadMessage.value = `${text}${label}:${file.name}`;
};
setProgress('正在上传');
try { try {
const res = await axios.post('/api/music/local/upload', file, { const res = await axios.post('/api/music/local/upload', file, {
headers: { headers: {
@@ -435,6 +467,11 @@ async function uploadLocalFiles(fileList: File[]) {
'X-Filename': encodeURIComponent(file.name), 'X-Filename': encodeURIComponent(file.name),
}, },
maxBodyLength: Infinity, maxBodyLength: Infinity,
onUploadProgress: (e) => {
if (!e.total) return;
const pct = Math.round((e.loaded / e.total) * 100);
setProgress(pct >= 100 ? '服务端处理中' : `正在上传 ${pct}%`);
},
}); });
if (res.data?.song) uploaded.push(res.data.song as Song); if (res.data?.song) uploaded.push(res.data.song as Song);
} catch (err: any) { } catch (err: any) {