feat(local): 支持上传并播放本地视频文件,只保留音轨 (#149)

本地上传此前只接受音频。想放一段本地 mp4/mov/avi 里的音乐,四道关卡
挡着(前两道在服务端,后两道在浏览器端):

1. src/music/local.ts 的 AUDIO_EXTENSIONS 只列了 12 种音频后缀;
2. src/web/api/music.ts 里 express.raw 的 type 只匹配 audio/*、
   video/webm、application/octet-stream —— 浏览器给 .mp4 打的
   Content-Type 是 video/mp4,请求体压根不会被解析,处理函数看到
   req.body === undefined,回 400「raw audio body is required」;
3. Search.vue 的 accept 属性让文件选择框把视频文件置灰;
4. isAudioFile() 把拖进来的视频文件静默丢掉。

ffmpeg 层不是瓶颈:s16le 输出格式不接受视频,ffmpeg 的自动选流本来
就只挑音轨。实测 mp4/mov/avi/mkv/flv/wmv/ts/m4v/mpg 九种容器用现有
参数全部正常出声,多音轨、带字幕、带 timecode 的也一样,所以
buildFfmpegArgs 一个字没动。

## 改动

- **打通四道关卡**:新增 VIDEO_EXTENSIONS(mp4/mov/avi/mkv/flv/wmv/
  m4v/mpg/mpeg/3gp/ts/m2ts/ogv),express.raw 收 video/*,前端 accept
  与过滤函数同步放宽。
- **上传时抽取音轨**(extractAudioTrack):视频落盘后用
  `-vn -sn -dn -map 0:a:0 -c:a copy` 把音轨原样搬进 Matroska 音频容器
  (.mka)再删掉原视频。`-c:a copy` 不重编码,无损、快,且 Matroska
  几乎收所有音频编码,不用维护「编码→后缀」对照表。实测 720p 素材
  落盘体积降到原文件的 14%,这对 5 GiB 的上传目录配额很关键——否则
  十来个视频就把配额占满了。抽取失败(冷门编码、超时)则保留原容器
  继续播,只是占地方,绝不会因此上传失败。
- **拒绝没有音轨的视频**:上传时探测,直接回「这个视频里没有音轨,
  无法播放」,而不是等到播放时静默跳过。只在 ffmpeg 确实打开了容器
  (打印了 `Input #0,`)时才拒绝——认不出的字节一律放行,截断的 mp3
  一直是这个行为,不能因为这次改动开始被拒。
- **上限从 200mb 提到 500mb**,并把超限响应从 Express 默认的 HTML
  错误页(带堆栈和服务器绝对路径)换成和本路由一致的 JSON;前端也加
  了同样的预检,不再传完几百兆才被拒。
- **上传进度**:视频比音频大得多,原来那句静止的「正在上传 N 个文件」
  看着像卡死,现在按文件显示百分比,传完切到「服务端处理中」。

## 验证

- 全量 `npx vitest run`:136 个文件 / 2070 项,新增 24 项。
- 新增测试用 ffmpeg 现造真实容器跑端到端:mp4 上传后时长正确、原
  容器已删、剩下的 .mka 能被播放链路解码出 PCM;avi/mkv/flv 同样;
  无音轨视频被拒且不留残留文件;纯音频上传字节数不变、不被重封装。
- 变异测试(逐个改回旧实现,确认新测试真的会红):后缀白名单 4 项失败、
  express.raw 的 type 5 项失败、抽取音轨 2 项失败、无音轨拒绝 2 项失败。
- `npx tsc --noEmit` 与 `npx vue-tsc --noEmit` 均 exit 0。

Reported-by: @LadenceE
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 5 committed 2026-08-14 01:20:01 +08:00
1 parent b92543f337
commit 28b3cd771f
6 files changed
+651 -42

No files matched your search

+47 -10
View File
@@ -28,18 +28,18 @@
>
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">拖拽本地音频到这里上传</div>
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
<div class="upload-title">拖拽本地音频 / 视频到这里上传</div>
<div class="upload-subtitle">音频支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等,视频支持 mp4、mov、avi、mkv、flv、wmv 等(只取其中的音轨播放);上传后可直接播放或加入队列</div>
</div>
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
{{ uploading ? '上传中...' : '选择音频' }}
{{ uploading ? '上传中...' : '选择文件' }}
</button>
<input
ref="fileInput"
class="file-input"
type="file"
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"
/>
</div>
@@ -404,8 +404,17 @@ async function doSearch() {
}
function isAudioFile(file: File): boolean {
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
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[]) {
@@ -414,10 +423,22 @@ async function uploadLocalFiles(fileList: File[]) {
uploadMessage.value = '本地音频播放已关闭';
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) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频文件';
uploadMessage.value = `文件太大,单个文件上限 ${UPLOAD_MAX_MB} MB:${oversize[0].name}`;
return;
}
@@ -426,8 +447,19 @@ async function uploadLocalFiles(fileList: File[]) {
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
const uploaded: Song[] = [];
const failed: string[] = [];
for (const file of files) {
const failed: string[] = oversize.map((f) => `${f.name}: 超过 ${UPLOAD_MAX_MB} MB 上限`);
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 {
const res = await axios.post('/api/music/local/upload', file, {
headers: {
@@ -435,6 +467,11 @@ async function uploadLocalFiles(fileList: File[]) {
'X-Filename': encodeURIComponent(file.name),
},
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);
} catch (err: any) {