从 QNAP 私人脚本到开源 CLI:我把 NAS 媒体归档工具做成公开项目的过程
这几天我把一个原本只服务于自己 NAS 的媒体整理脚本,逐步做成了一个可以公开发布的 Go CLI 项目:nas-media-archiver。
这个过程很适合记录下来,因为它并不是一个“先设计好架构,再按部就班实现”的过程。相反,它更像是一次不断做减法、不断靠近问题本质的工程收敛。
起点其实很简单
一开始的需求非常直接:
- NAS 上有很多手机备份出来的照片和视频
- 目录结构混乱,来源很多
- 文件名没有统一规范
- 重复文件很多
- 时间信息不稳定
目标也很明确:把这些文件从输入目录整理到归档目录,统一命名成:
1 | YYYYMMDD_HHMMSS_SIZE.ext |
并按年月放到:
1 | photos/YYYY/MM |
看起来像是一个脚本问题,但真正做起来,很快就不是“写几行 shell”能解决的了。
第一轮误区:把问题想复杂了
最初我试图把它设计成一个更“完整”的系统:
- Mac 桌面客户端
- NAS 端服务
- HTTP API
- 后面又想过 gRPC
- 甚至想过 Web 前端和 Next.js
这些东西单看都合理,但放到这个问题里,其实是偏离了主轴。
因为这个项目真正的核心,不是“远程控制”,也不是“多端 UI”,而是:
- 本地文件扫描
- 元数据提取
- 去重
- 原子移动
- 状态持久化
- 失败恢复
也就是说,它的本质是一个运行在 NAS 本机上的文件处理程序。
一旦把这个边界想清楚,很多复杂度就可以直接砍掉:
- 不需要桌面客户端
- 不需要 Web
- 不需要 server
- 不需要 gRPC
- 不需要数据库
最后收敛出来的形态反而非常朴素:
- 一个单机 Go CLI
- 本地
job.json - 本地
events.jsonl - 直接在 NAS 上运行
这是整个过程里最重要的一次判断。很多工程工作,难点不在于“怎么做”,而在于“哪些东西根本不该做”。
为什么最终没有继续用 shell
如果只是扫目录、跑 find、调用 ffmpeg、mv 一下,shell 脚本当然也能做。
但一旦需求里包含下面这些条件,shell 就开始显得吃力:
- 每个文件要有明确状态
- 任务中断后要能恢复
- 要能查询失败文件
- 要能做 dry run
- 要能导出结果
- 要能逐步优化性能
shell 适合线性流程,不适合承载复杂状态机。
所以最后的方向不是“继续堆 shell”,而是做一个比脚本更可维护、但比服务架构更轻的工具。
Go 很适合这个中间地带。
真正的瓶颈,从来都不是语言本身
在实现过程中,我一度也想过:
- 要不要换 Rust?
- 要不要纯 C?
- Go 会不会太慢?
但真实测下来,瓶颈根本不是 “Go vs Rust vs C”。
主要瓶颈其实是这几类:
- 每个文件整份重写
job.json - 每条事件都反复打开/关闭
events.jsonl - 完全串行处理
- 视频元数据依赖外部进程
- 跨文件系统移动导致
rename失败,需要复制
也就是说,瓶颈主要来自:
- I/O 模式
- 外部进程
- 任务模型
- 状态持久化方式
不是来自语言 runtime。
哪些优化是真的有效
后面做了几轮优化,对性能影响最大的不是大改语言,而是两类结构性调整。
1. 降低状态持久化开销
最早的版本里,每处理一个文件就:
- 重写整份
job.json - 单独打开一次
events.jsonl
这在几百个文件时还能忍,到了几万甚至十万文件就会非常浪费。
后来改成:
job.json批量写events.jsonl在运行期间保持文件句柄打开
这一步本身就能带来明显改善。
2. metadata 并发,writer 单线程
我没有把整个流程都做成完全并发,而是保留了一个我认为更稳的边界:
- metadata / planning 并发
- 最终写入单线程
这样做的好处是:
- 前面的探测和规划可以提速
- 最后文件写入仍然容易保证正确性
- 不容易引入重复写、路径冲突或奇怪竞态
在一台 4 核 ARM 的 QNAP NAS 上,dry run 的最佳点最后落在:
1 | --workers 8 |
比早期单线程版本大概有 2 倍以上提升。
这个结果也进一步验证了一件事:先改处理模型,通常比先换语言更值。
真机运行时,问题往往不是你在本地能想到的那个
真正把程序跑到 NAS 上以后,出现过几个非常真实的环境问题。
1. 输入目录和归档目录不在同一文件系统
最开始我默认 rename 就能完成移动,结果真机一跑,直接报:
1 | invalid cross-device link |
原因是输入目录和输出目录不在同一个文件系统上。
后面改成了:
- 先复制到目标目录的临时文件
fsync- rename 到正式文件名
- 删除源文件
这一步属于“不是理论优化,但是真机必须处理”的工程现实。
2. QNAP 的 ACL 和 admin 模型
QNAP 不是普通 Linux。
它的共享目录、ACL、admin 权限模型都有自己的行为方式,导致“程序能跑”和“程序能正确写共享目录”是两件事。
最后比较可靠的执行方式是:
- 普通用户负责上传和扫描
- 真实归档时
sudo -u admin
这件事如果不记录清楚,别人即使拿到程序,也很容易以为是工具坏了,实际上是权限模型不一样。
3. 源文件在运行过程中消失
在实际批量归档时,我碰到过几次:
- 任务运行到一半
- 同步系统把源文件移动或清掉了
- 程序报
no such file or directory
早期实现会把整个 job 标成 failed。
后来我把这个逻辑改成:
- 如果文件在处理过程中已经不存在
- 标记成
skipped - 不把整个 job 当成灾难性失败
这是那种只有在真实环境里连续跑很多目录之后,才会意识到必须补的边角。
从私人脚本变成公开仓库,难点不在代码
真正把项目推上 GitHub 时,我发现最麻烦的不是“发布”,而是“去个人化”。
比如这些东西都不适合直接公开:
- NAS 的 IP
- SSH 用户名和密码
- 私有部署路径
- 自己真实目录名
- 真实 job id
- 某次归档的运行记录
所以我做了几轮整理:
1. 把敏感信息移到 .secrets
公开仓库不应该出现任何真实连接信息。
2. 文档改成通用占位符
像下面这种:
1 | /share/ssd/upload/iphonexs-2026-03 |
都不应该出现在公共文档里。
要改成:
1 | /path/to/input |
3. 删掉太 personal 的运行报告
我原来还保留了一篇真实归档报告,里面有真实文件名、真实路径、真实时间和性能细节。
这类内容对自己有价值,但不适合作为 public repo 的正式文档。最后我把它删掉了,只保留泛化后的 benchmark 结论。
4. 调整文档结构
原来仓库根目录里散着很多中文文档名。
后来统一整理成:
README.mddocs/usage.mddocs/design.mddocs/benchmarks.mddocs/qnap-notes.md
开源仓库的第一印象,往往不是代码,而是目录结构和 README。
这次做对的几件事
如果回头看,我觉得有几件事是做对了的。
1. 不断做减法
从桌面客户端、Web、gRPC,最后收敛成单机 CLI。
2. 先解决真实瓶颈
没有急着重写语言,而是先处理:
- 状态写入开销
- 外部进程成本
- 并发模型
- 跨文件系统移动
3. 在真实环境里反复跑
很多问题不是代码审查能看出来的,只能靠真机、大量目录、长时间执行去暴露。
4. 在开源前认真做清理
至少要把:
- 敏感信息
- 个人路径
- 临时运行记录
- 过于内部化的文档
清掉之后,才像一个别人可以真正使用的项目。
还没做完的部分
项目虽然已经能用了,但还远远不是“完成态”。
后面比较值得继续做的还有:
- 支持
gif/webp - 增加回归测试
- 优化
watch/status的统计输出 - 继续降低视频元数据 fallback 的成本
最后
这次过程对我最大的提醒其实很简单:
工程里最重要的能力之一,不是把系统做大,而是把系统做对。
很多时候,最有价值的不是多加一层架构,而是少加一层架构。不是多引入一个组件,而是确定这个组件根本没必要存在。
最后这个项目落成的形态很朴素:
- 一个 Go CLI
- 一些本地状态文件
- 一套可恢复、可追踪、可批量执行的归档流程
但它已经足够解决真实问题。