音乐索引工程笔记
把 9TB 个人音乐库做成一个能查询的索引,听起来像写脚本就能搞定。真正难的不是写代码,是先把「绝对不动原盘」这件事钉死。
我家里那块硬盘装着 115,999 个音频文件,FLAC、MP3、WAV、DSF 都有,加起来 7.39 TB,差不多把一块 9TB 盘塞满。这批东西攒了十几年——有自己抓的 CD,有朋友传的,有早年下载的,有从老硬盘救回来的。每一份背后都有点故事,但故事不重要,重要的是它现在是一坨黑箱:我知道它在那儿,却查不动它。
第一次想做索引(index)的时候,我差点直接上手——扫一遍、读 metadata(元数据)、丢进数据库、给它配个搜索界面。后来停住了。但我见过太多人——也包括早年的我自己——一动手就把原盘写坏过。重命名、移动、改 tag、加封面图,每一次都是「就这一下」,每一次回头看都觉得「当时不该动」。
所以这次我先做一件反直觉的事:把规则写在代码前面。五条约束,写得比业务流程还重。脚本可以重写,约束不能破。
第一条:只读原则——禁止动原盘任何文件
最朴素的一条,也是最容易被破的一条。只要扫描脚本里出现 open(path, 'w')、出现 shutil.move、出现 os.rename,整条约束就废了。
我把它写得很死——不修改、不移动、不删除、不重命名。连「顺手把文件名规范一下」都不行。原因不是技术,是信任:这块盘是我十几年的资料,我不允许任何脚本对它做「我觉得对你好」的事。索引可以重做,原盘改坏了就回不去。
实际落地是工具层拦截。所有走原盘的路径,只允许 read 模式打开;任何写入意图直接 raise。听起来夸张,但跑了几周下来,这一条挡掉的不只是我自己的失误——还挡掉了几次 AI 想「帮我清理一下文件名」的越界。
第二条:禁止在原盘写任何东西——隔离工作区
第一条的延伸,但比它更细。不仅不能改原文件,也不能在原盘里建临时数据库、缓存、日志、状态文件。
这条我以前没意识到要单独立。直到有一次我用某工具扫照片库,扫完发现它在每个子目录里默默生成了一个 .cache 文件。表面无害,但原盘从此就不再「干净」——它带了这个工具的痕迹,下次换工具还要先清理。
所以现在所有索引输出——数据库、缓存、ffprobe 报告、错误日志、断点状态——全部写在另一块工作盘的专用工作目录里。原盘永远只承担「数据源」这一个角色,不承担工作台。两者物理隔离,连挂载点都分开。
这一条带来的副作用很爽:原盘可以随时卸载、随时换接口、随时复制到另一台机器,索引那边一点都不疼。
第三条:禁止联网识别——纯本地处理
这条最容易被现代工具背刺。MusicBrainz、AcoustID、各种在线歌词识别——只要你不主动关掉,它们就默认开着。每扫一首歌就发一次指纹去对,几天下来你的整个音乐库被人云端「画过像」了。
我不想要这种便利。一是隐私——我的私人音乐听感、口味、收藏构成,没必要交给任何在线服务做训练样本。二是稳定——联网识别会让索引结果依赖于「当时云端返回了什么」,今天匹到的明天可能匹不到,索引就不再是可重现的了。
所以纯本地。metadata 只读文件自己带的 tag,质量评分只看文件属性,重复判定只看本地哈希和时长。能不能识别得更准?当然能。但识别更准的代价是失去「这套索引完全可重建、完全可离线」这个属性,我换不起。
第四条:断点续扫——重复运行不能重复写
11 万多个文件全量跑一次不是 5 分钟的事。第一次跑完整的 ffprobe(提取音频元数据的工具)+ mutagen(另一个 metadata 读取工具)扫描,连续跑了一夜还没收。中途网络断、电源跳、自己手贱按了 Ctrl-C,都得能从断点继续。
断点续扫听起来简单,做起来全是坑。最大的坑是「重复写入」——上次扫到一半中断,下次重启时如果不去重,同一首歌就被插了两次。整个索引数据从此不可信。
这条约束的真正含义,其实不是「能从中断处继续」,而是「重复运行同一个脚本,结果必须收敛到同一份索引」。同一个文件路径只允许有一行记录,幂等是底线。技术上靠数据库唯一索引 + 显式 upsert(更新或插入)+ 一份独立的「已处理路径」表三件套来保证。
第五条:错误隔离——损坏文件不能中断任务
11 万个文件里有损坏的、有权限不对的、有文件名编码诡异的、有格式头崩坏的。最终统计下来,543 个文件 ffprobe 读不出,744 个文件 mutagen 失败。两千多个错——但全量扫描不能因为这两千多个错而停。
早期版本我图省事,遇到 exception 直接抛出来。结果脚本跑到 30% 就死了,重启从 0 再跑,跑到 35% 又死了。永远在前 40% 里打转。
后来改成「每个文件独立 try-except,错误进单独的 error log,主流程继续」。这一条之后才真的扫得完。错误隔离说到底就是承认现实——一个 11 万文件的库里出几千个错是正常的,不正常的是因为这几千个错就放弃剩下的 10 万。错误要记录,不能用沉默掩盖;但记录归记录,主任务不停。
为什么要分十个阶段——按风险切,不按功能切
五条约束钉死之后,我没有写一个「一键扫描」的大脚本。我把整个工作切成了十个阶段,每个阶段单独跑、单独验、单独收口。
切阶段的逻辑跟切功能不一样。切功能是「这块代码负责什么」,切阶段是「这一步能炸出什么风险」。按风险切的好处是:某一阶段失败了,损失的只是这一阶段的成本,前面的不用回炉。
- Stage 0:安全检查 + 工作目录初始化——确认原盘只读、工作盘可写、所有路径都不会越界。
- Stage 1:依赖检查——ffprobe、mutagen、Python 环境、数据库 schema 全部就位。
- Stage 2:音频文件发现——只读扫描全盘,把 11 万多个文件的路径和大小列出来。不读 metadata,先确保走完一遍盘。
- Stage 3:抽样验证——抽 300 个文件试着读 metadata,看成功率多少,估算全量要多久。
- Stage 4:全量 metadata 读取——基于抽样的估算放心跑全量,断点续扫开着。
- Stage 5:重复候选分析——只「标记」可能重复的文件,绝对不删。
- Stage 6:质量评分草案——给每首歌一个 0-100 的分。
- Stage 7:AI DJ 初始索引 v0——把前面所有数据合成一份可查询的索引。
- Stage 8:最终收口——对照原盘统计校验完整度。
- Stage 9:索引验收与修补——人工抽查几百条,找规则漏洞。
- Stage 10 及以后:播放器集成、标签增强、音频特征分析——这些是另一摊事,等前 9 个稳了再说。
分十阶段最大的好处是——任何一阶段失败,我只回退这一阶段。Stage 4 跑了一夜失败,前面 Stage 0-3 还在,重跑只重跑这一段。而如果是一个大脚本一夜跑完,失败就得全部重来。
另一个好处是——每个阶段有自己的「通过标准」。Stage 3 抽样如果成功率低于 90%,就不进 Stage 4,先回去查为什么失败。后面阶段的输入才能干净。
质量评分的六个维度——为什么不做听感测试
Stage 6 的质量评分是整个索引最容易被质疑的部分。有人会问:你怎么不做 ABX 听感测试(盲听对比,业内最严谨的音质评估方法)?怎么不做频谱分析?怎么不算动态范围?
我都想过。但最终选择了六个非常笨的维度:
- 无损还是有损——FLAC、WAV、DSD 起步比 MP3 高。
- 采样率和码率——高采样率加分,码率太低扣分。
- metadata 完整度——title、artist、album、year、genre 缺哪个扣哪个的分。
- duration 正常性——时长异常的(比如 5 秒或 8 小时)单独标记。
- 读取成功率——ffprobe 或 mutagen 任一失败的扣大分。
- 疑似重复——和 duplicate group(重复候选组)关联的扣关联分。
为什么不做听感?它太主观,没法机器化;而我要的是一份能跑、能复现、能在 11 万首歌上一视同仁打分的索引。一旦引入听感,第二天我自己重听就会想推翻第一天的判断,整个评分就再也不稳定了。
六个维度都是文件层面的客观属性——同一个文件今天跑明天跑结果完全一样。这才是「索引」该有的样子,不是音质评测。整体平均分跑下来是 78.1,分布也比较合理——FLAC 普遍 85 以上,MP3 普遍 60-75,少数损坏文件拉到 30 以下。够用了。
三个踩坑实录——约束钉得再死也会被啃出洞
五条约束 + 十个阶段,听起来很周全。实际跑下来还是被坑了好几次。挑三个最典型的说。
Unicode NFC/NFD 规范化
macOS 上的文件名走 NFD 编码(把组合字符拆开存),Linux 上很多脚本默认按 NFC(合成形式)处理。同一个中文歌曲名字,在 macOS Finder 里看着没问题,但 Python 拿到那个路径去 os.stat,可能就报「文件不存在」。
这个坑卡了我两天。一开始以为是权限问题,查了半天没结果。后来肉眼对比两个看似一样的字符串才发现——它们字节级别不一样。修法是在所有路径进入数据库之前,统一 normalize 成 NFC。这不属于「改原盘」——原盘上的文件名一个字节都没动,只是数据库里存的是规范化后的版本。
insert bug
Stage 5 做重复候选分析的时候,要把每个文件和它的潜在重复对一起插入一张表。我第一版图省事,没加 unique constraint(唯一约束),结果同一对文件被插了三遍——分析逻辑里好几条规则都会判定它们是重复。
表面上没坏,查询时多几条记录而已。但接到 Stage 6 评分时就出事了——同一首歌因为有「三条重复关联」被扣了三次分,掉到了不该掉的分段。修法是显式加唯一约束 +(pair_a, pair_b)双向去重,并把 Stage 5 的输出当作 Stage 6 的输入前先 verify 一次。
教训不是「记得加 unique constraint」,而是——任何「分析」类的脚本默认会重复触发,必须在数据层挡死,不能依赖业务层小心。
ffprobe timeout
Stage 4 全量跑 metadata 的时候,遇到几个超大的 DSF 文件(单文件几个 G),ffprobe 读到一半卡住,子进程没退出,主脚本也不前进。一夜下来扫了几百个就死了。
修法是给每次 ffprobe 调用加 watchdog(看门狗,定时检查子进程的机制)——超过 30 秒强制 kill 子进程,错误进 log,文件标记成「读取超时」,主流程继续。这套补丁打上之后,Stage 4 才真正能一夜跑完。
这事反过来让我重新审视第五条约束「错误隔离」——错误隔离不仅要管 exception,还要管「不返回也不报错」的情况。沉默挂起比抛错更难抓,必须主动加超时。
当前进度——Stage 13B 还在跑
Stage 0 到 12 已经跑完。索引第一版(v0)能跑能查,11 万多个文件全部进表,metadata 完整度大致是 title 90%、artist 90%、album 89%、year 33%、genre 35%。前三个还行,后两个偏低——很多老 MP3 当年抓盘时压根没填 year 和 genre。
Stage 13B 在做的事是反向校验——拿索引里的统计数据和原盘上的实际目录结构再对一遍,看有没有「索引里有但盘上找不到」或者「盘上有但索引里缺失」的情况。这一步本来该是 Stage 8 收口的活儿,但 Stage 8 当时偷了懒只做了正向校验,只好另开一个 Stage 13B 补回来。
跑到现在已经发现两件小事——有 200 多个文件路径里带罕见字符,没被 Stage 2 发现,得回到 Stage 2 加 NFC 规范化重扫;还有 40 多个文件 metadata 是空的但文件本身正常,看起来是当年某个工具抓的时候就没写。这两个修完,v0 就算正式定稿。
再往后是 Stage 14+ ——播放器集成、AI 推荐 DJ、可视化界面。但那些都建立在「索引可信」这个基础上,我不急着推进。索引不稳,上层一切都是沙子。
这个索引远没做完。但每加一条约束、每跑通一个阶段、每填一个踩坑修补,我对它的信任就多一分。
我现在不再追求「快点把整套查询界面做出来」。我追求的是——这套索引下个月、半年后、两年后,我重新跑同一份脚本,结果还能收敛到同一份。原盘永远没被动过,输出永远在工作盘里,错误永远在 log 里,重复运行永远幂等。这四件事比任何花哨的查询前端都重要。
Stage 13B 还在跑。下一篇关于这套索引的笔记,多半会从「v0 终于敢叫 v1」或者「某条约束又被啃出新洞」开始讲。