想做什么

手头有一批不同格式的音频文件,想统一转成 mp3。不想装一堆依赖,也不想把文件传到任何在线服务——最好是双击一下、浏览器打开、拖进去就能转的本地小工具。

于是就有了这个项目:一个零第三方依赖的本地 Web 工具。后端只用 Python 标准库,前端纯原生 HTML/JS,转码交给 ffmpeg。整个流程数据不出本机。

整体架构

1
2
3
4
5
6
7
浏览器(拖拽上传 / 展示 / 下载)
│ HTTP (127.0.0.1:8000)
▼
本地 Python 服务(标准库 http.server)
│
├─→ 核心库(解析文件、探测格式)
└─→ ffmpeg 子进程(转码成 mp3)

前端和后端通过几个简单的 HTTP 接口通信,后端跑在本机 127.0.0.1,只有自己能访问。

后端:只用标准库搭 HTTP 服务

没装 Flask、没装 FastAPI,直接基于标准库的 http.server。

1. 一个 Handler 处理所有请求

1
2
3
4
5
6
7
8
9
import http.server, socketserver

class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
# 处理静态页面 + 查询接口
...
def do_POST(self):
# 处理文件上传(转换)
...

启动时用多线程版本,并允许端口复用,方便重启:

1
2
3
socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("127.0.0.1", 8000), Handler) as httpd:
httpd.serve_forever()

ThreadingTCPServer 让每个请求跑在独立线程里,多文件转换不会互相阻塞;allow_reuse_address 让你重启服务时不会因为端口还占着而报错。

2. 静态文件服务 + 目录穿越防护

前端页面就是几个静态文件,直接读出来返回。但不能直接把用户传来的路径拼上去,否则 /../../etc/passwd 这种目录穿越就能读到任意文件:

1
2
3
4
target = (WEB_DIR / path.lstrip("/")).resolve()
if not str(target).startswith(str(WEB_DIR.resolve())) or not target.is_file():
self.send_error(404, "Not Found")
return

resolve() 把路径规范化成绝对路径,再校验它确实落在 WEB_DIR 目录内,越界就 404。

3. 按 Content-Length 精确读上传体

接收上传的文件时,先读请求头里的 Content-Length,再精确读那么多字节,而不是一直读到超时:

1
2
3
4
5
length = int(self.headers.get("Content-Length", "0"))
if length > 512 * 1024 * 1024: # 上限 512MB,防止恶意超大请求
self.send_error(413, "文件过大")
return
data = self.rfile.read(length)

4. 中文文件名的正确姿势

文件名通过自定义请求头 X-Filename 传过来(前端用 encodeURIComponent 编码过)。服务端解码后,再用 pathlib.Path(name).name 把可能的路径部分剥掉,避免文件名里夹带路径:

1
2
raw_name = urllib.parse.unquote(self.headers.get("X-Filename", "input.mflac"))
safe_name = pathlib.Path(raw_name).name

返回 mp3 时,用 RFC 5987 的方式编码中文文件名,否则浏览器会拿到一串乱码:

1
2
disp = f"attachment; filename*=UTF-8''{urllib.parse.quote(out_file.name)}"
self.send_header("Content-Disposition", disp)

前端:拖拽上传 + 批量 + 下载

前端没用任何框架,就原生 DOM + fetch。

拖拽上传

监听 drop 事件拿到文件列表,过滤出想要的扩展名,再逐个处理:

1
2
3
4
dropzone.addEventListener("drop", (e) => {
const files = e.dataTransfer.files;
if (files.length) handleFiles(files);
});

逐个上传 + 进度

批量时按顺序一个个转,避免同时打爆后端:

1
2
3
4
5
for (let i = 0; i < files.length; i++) {
statusText.textContent = `正在转换 ${i + 1}/${files.length}:${files[i].name}`;
const blob = await convertOne(files[i]);
...
}

单个文件用 fetch POST,请求体直接就是文件本身,文件名放 X-Filename 头:

1
2
3
4
5
6
const resp = await fetch("/api/convert", {
method: "POST",
headers: { "X-Filename": encodeURIComponent(file.name) },
body: file,
});
return await resp.blob();

把响应存成文件下载

后端返回的是 mp3 二进制,用 Blob 包一层,再借一个临时 <a> 标签触发下载:

1
2
3
4
5
6
7
8
9
10
function downloadBlob(name, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000); // 用完释放
}

URL.revokeObjectURL 那一步很重要——createObjectURL 会占住内存,不释放的话批量下多了会越占越多。

选整个文件夹

给 <input type="file"> 加上 webkitdirectory,就能让用户直接选一整个文件夹:

1
<input type="file" id="folderInput" webkitdirectory multiple hidden>

音频处理管道

核心库负责两件事:探测容器格式 和 调 ffmpeg 转码。

探测格式:读文件头的魔数

不同音频容器的开头有几个固定的「魔数」字节,读一下就知道是什么格式,不用靠扩展名猜:

1
2
3
4
5
6
7
8
9
10
11
12
with path.open("rb") as f:
head = f.read(64)
if head.startswith(b"fLaC"):
return "flac"
if head.startswith(b"OggS"):
return "ogg"
if head.startswith(b"ID3"):
return "mp3"
if len(head) >= 2 and head[0] == 0xFF and head[1] in (0xFB, 0xF3, 0xF2):
return "mp3"
if len(head) >= 12 and head[4:8] == b"ftyp":
return "m4a"

交给 ffmpeg 转码

转码用子进程调 ffmpeg,输出 320k 的 mp3:

1
2
3
4
5
6
7
8
cmd = [
ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
"-i", str(input_path),
"-map", "0:a:0", "-vn", "-sn", "-dn",
"-codec:a", "libmp3lame", "-b:a", f"{bitrate}k",
str(output_path),
]
subprocess.run(cmd, capture_output=True, ...)

几个值得说的参数:

  • -map 0:a:0 -vn -sn -dn:只取第一条音频流,丢掉视频、字幕、附件,转纯音频。
  • -codec:a libmp3lame:指定用 LAME 编码器出 mp3。
  • -b:a 320k:目标码率。

ffmpeg 是个独立的二进制,单独放一个 bin/ 目录,程序启动时按「显式路径 → 项目自带 → PATH」的优先级自动找,找不到就给出友好提示。

还有个 Windows 小细节:用 subprocess 调 ffmpeg 时,如果不处理会闪一下黑色控制台窗口。加两行 STARTUPINFO 就没了:

1
2
3
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.run(cmd, capture_output=True, startupinfo=startupinfo)

踩过的坑

  1. Windows 控制台打不了 ✓——默认 GBK 编码,一 print emoji 就 UnicodeEncodeError。解决:sys.stdout.reconfigure(errors="replace"),打不出来的字符自动替换成 ?,不崩了。
  2. 中文文件名下载变乱码——要用 RFC 5987 的 filename*=UTF-8''...,而不是老的 filename=。
  3. 忘了做目录穿越防护——静态文件路径必须 resolve() 后校验范围,否则能被读到任意文件。
  4. createObjectURL 忘了释放——批量下载会内存泄漏,记得 revokeObjectURL。
  5. 文件名带路径——上传文件名要 pathlib.Path(name).name 剥干净,否则写临时文件时可能写到别的目录去。

下一步

  • 用 PyInstaller 打包成单个 .exe,双击就用,不依赖 Python 环境。
  • 前端加个并发上限(比如同时最多 2 个),大批量时更快又不压垮本机。
  • 加个任务队列,转换中关页面也不丢进度。

这次最大的收获是:不装任何框架,光靠标准库也能搭出一个体面的本地 Web 工具。http.server 加 socketserver 完全够用,剩下的就是几个边界细节(路径、编码、文件名)磨平。工具全程跑在本机,处理的是自己本地的文件,数据不出一台电脑。