From 28b3cd771f81435950f90b1ca249404de0e263ea Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 14 Aug 2026 01:20:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(local):=20=E6=94=AF=E6=8C=81=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=B9=B6=E6=92=AD=E6=94=BE=E6=9C=AC=E5=9C=B0=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=96=87=E4=BB=B6=EF=BC=8C=E5=8F=AA=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E9=9F=B3=E8=BD=A8=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地上传此前只接受音频。想放一段本地 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) --- README.md | 2 +- src/music/local.test.ts | 227 +++++++++++++++++++++++++++++++++++++- src/music/local.ts | 207 ++++++++++++++++++++++++++++++---- src/web/api/music.test.ts | 145 +++++++++++++++++++++++- src/web/api/music.ts | 55 ++++++++- web/src/views/Search.vue | 57 ++++++++-- 6 files changed, 651 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index ac3cefe..4069ca0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节 - **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步 - **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明) -- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 +- **本地音视频上传播放** — 在搜索页拖拽或选择本地文件上传,音频(mp3 / flac / wav / m4a / ogg / opus 等)和视频(mp4 / mov / avi / mkv / flv / wmv 等)都支持,视频上传后只保留其中的音轨;上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件 - **专属链接(单机器人锁定)** — 通过 `/bot/` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户 - **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启) - **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源) diff --git a/src/music/local.test.ts b/src/music/local.test.ts index d82e066..58cfd2e 100644 --- a/src/music/local.test.ts +++ b/src/music/local.test.ts @@ -1,8 +1,11 @@ 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 { join } from "node:path"; -import { LocalMusicProvider } from "./local.js"; +import { LocalMusicProvider, parseMediaProbe } from "./local.js"; let dir: string; @@ -149,6 +152,115 @@ describe("LocalMusicProvider upload validation", () => { p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }), ).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", () => { @@ -233,3 +345,114 @@ describe("LocalMusicProvider filename handling", () => { 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. + expect(resolved!.url.endsWith(".mka")).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)("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); +}); diff --git a/src/music/local.ts b/src/music/local.ts index cc52bbf..692a84a 100644 --- a/src/music/local.ts +++ b/src/music/local.ts @@ -1,5 +1,5 @@ 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 path from "node:path"; import crypto from "node:crypto"; @@ -34,6 +34,37 @@ const AUDIO_EXTENSIONS = new Set([ ".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", +]); + +/** Container the extracted audio track is remuxed into. Matroska takes + * essentially any audio codec, so `-c:a copy` works without knowing what the + * source used — no re-encode, no quality loss, no codec/extension table. */ +const EXTRACTED_AUDIO_EXT = ".mka"; + +function isSupportedUploadExt(ext: string): boolean { + return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext); +} + const DEFAULT_MAX_FILES = 200; const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB @@ -71,39 +102,125 @@ function titleFromFileName(name: string): string { return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频"; } -async function probeDurationSeconds(filePath: string): Promise { +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; + /** + * True when ffmpeg actually opened the container and printed its + * `Input #0, , 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 { + 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 hasAudio = /Stream #\d+:\d+[^\n]*:\s*Audio:/.test(stderr); + // "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, recognized }; +} + +async function probeMedia(filePath: string): Promise { return new Promise((resolve) => { const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], { stdio: ["ignore", "ignore", "pipe"], }); 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(() => { ffmpeg.kill("SIGKILL"); - resolve(0); - }, 5000); + done({ durationSeconds: 0, hasAudio: false, recognized: false, probed: false }); + }, 20000); ffmpeg.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); ffmpeg.on("error", () => { clearTimeout(timeout); - resolve(0); + done({ durationSeconds: 0, hasAudio: false, recognized: false, probed: false }); }); ffmpeg.on("close", () => { clearTimeout(timeout); - const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/); - if (!match) { - resolve(0); - return; - } - 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); + done({ ...parseMediaProbe(stderr), probed: true }); }); }); } +/** + * 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 { + const ok = await new Promise((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; + } +} + export class LocalMusicProvider implements MusicProvider { readonly platform = "local" as const; private readonly uploadDir: string; @@ -171,33 +288,75 @@ export class LocalMusicProvider implements MusicProvider { const ext = path.extname(originalName).toLowerCase(); // Validate by the (sanitised) file extension only — never trust the // client-supplied Content-Type. This also guarantees the STORED extension - // is one of the known audio types, so a spoofed header cannot persist an - // arbitrary-extension blob on disk. - if (!AUDIO_EXTENSIONS.has(ext)) { - throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等"); + // is one of the known audio/video types, so a spoofed header cannot + // persist an arbitrary-extension blob on disk. + if (!isSupportedUploadExt(ext)) { + throw new Error( + "只支持常见音频文件(mp3、flac、wav、m4a、ogg、opus、aac、webm 等)" + + "和视频文件(mp4、mov、avi、mkv、flv、wmv 等,仅取其中的音轨播放)", + ); } if (!input.buffer || input.buffer.length === 0) { throw new Error("上传文件为空"); } const id = crypto.randomUUID(); - const storedName = `${id}${ext}`; - const filePath = path.join(this.uploadDir, storedName); + const isVideo = VIDEO_EXTENSIONS.has(ext); + let filePath = path.join(this.uploadDir, `${id}${ext}`); writeFileSync(filePath, input.buffer); - const duration = await probeDurationSeconds(filePath); + let probe: MediaProbe; + try { + probe = await probeMedia(filePath); + } catch { + probe = { durationSeconds: 0, hasAudio: false, 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. + const extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`); + if (await extractAudioTrack(filePath, extracted)) { + try { + size = statSync(extracted).size; + rmSync(filePath, { force: true }); + filePath = extracted; + } 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 = { id, name: titleFromFileName(originalName), artist: "本地上传", album: "本地音乐", - duration, + duration: probe.durationSeconds, coverUrl: "", platform: "local", filePath, originalName, uploadedAt: new Date().toISOString(), - size: input.buffer.length, + size, mimeType: input.mimeType || "application/octet-stream", }; diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index 8cd1297..42f10ef 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -14,7 +14,7 @@ import { createSessionStore } from "../../data/sessions.js"; import { createPermissionStore } from "../../data/permissions.js"; import { createRequireAuth } from "../middleware/requireAuth.js"; import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; -import { createMusicRouter } from "./music.js"; +import { createMusicRouter, createLocalUploadBody } from "./music.js"; const empty: SearchResult = { songs: [], albums: [], playlists: [] }; @@ -295,3 +295,146 @@ describe("music router POST /quality — persistence (#125)", () => { expect(config.audioQuality.netease).toBe("lossless"); }); }); + +// #149: video containers must survive the transport layer. Before this the +// express.raw type filter only matched audio/*, video/webm and +// application/octet-stream, so a browser-sent video/mp4 body was never parsed +// and the handler answered 400 "raw audio body is required". +describe("music router POST /local/upload — content types and size cap (#149)", () => { + let app: express.Express; + let botDb: BotDatabase; + let cookie: string; + let uploadAudio: ReturnType; + + 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(); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index ff1455d..3c5865e 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -7,6 +7,56 @@ import { requirePermission } from "../middleware/requirePermission.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { authorize } from "../middleware/authorize.js"; +/** + * Body cap for a local upload. express.raw buffers the whole body in memory, + * so this is also the peak RAM one upload can cost — raised from 200mb for + * video (#149), which is far bigger than audio for the same song, but kept + * well short of "any video file at all" for that reason. Only the audio track + * survives to disk. + */ +export const LOCAL_UPLOAD_LIMIT = "500mb"; + +/** + * Body parser for the local-upload route. + * + * `type` includes "video/*" (#149): the browser sends the File's own MIME + * type, so an .mp4 arrives as video/mp4 and used to be rejected by this + * filter before ever reaching the provider. Only the audio track is kept — + * uploadAudio remuxes it out on the way in. + * + * express.raw hands an oversize body to the default error handler, which + * answers with an HTML page carrying a stack trace and absolute server paths + * (unless NODE_ENV=production, which this project never sets). Video makes + * hitting the cap far more likely than audio did, so that one case is + * translated into the same JSON shape the rest of this route returns. Any + * other body-parser error is passed on untouched. + * + * Exported as a factory so tests can drive the identical path with a small + * limit instead of allocating half a gigabyte. + */ +export function createLocalUploadBody(limit: string): express.RequestHandler { + const raw = express.raw({ + type: ["audio/*", "video/*", "application/octet-stream"], + limit, + }); + return (req, res, next) => { + raw(req, res, (err?: unknown) => { + if (!err) { + next(); + return; + } + const e = err as { type?: string; status?: number }; + if (e?.type === "entity.too.large" || e?.status === 413) { + res.status(413).json({ error: `文件太大,单个文件上限 ${limit}` }); + return; + } + next(err); + }); + }; +} + +const localUploadBody = createLocalUploadBody(LOCAL_UPLOAD_LIMIT); + export function createMusicRouter( neteaseProvider: MusicProvider, qqProvider: MusicProvider, @@ -65,10 +115,7 @@ export function createMusicRouter( } next(); }, - express.raw({ - type: ["audio/*", "video/webm", "application/octet-stream"], - limit: "200mb", - }), + localUploadBody, async (req, res) => { try { if (!localProvider) { diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue index 3792256..60fb1f3 100644 --- a/web/src/views/Search.vue +++ b/web/src/views/Search.vue @@ -28,18 +28,18 @@ >
-
拖拽本地音频到这里上传
-
支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列
+
拖拽本地音频 / 视频到这里上传
+
音频支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等,视频支持 mp4、mov、avi、mkv、flv、wmv 等(只取其中的音轨播放);上传后可直接播放或加入队列
@@ -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) {