
整理不是目的,在播放器里好好听才是。 本文把两件事合成一条闭环:先在电脑上把曲库整干净,再把它交给 APlayer 在手机上播放,并用播放器的界面反过来验收整理成果。 代码只占很小一部分,值钱的是怎么定标准、怎么判断"该不该改"、以及哪些坑会让你白干一整晚。 实战结果:959 首 MP3,930 首完全合格,剩下 29 首诚实地留白。
一、先想清楚:整理到底在整理什么
大多数人一上来就问"用什么脚本批量改标签"。但真正的问题不是改,是没有标准。
而且标准不是自娱自乐——你定的每一条,最后都会在播放器的歌单、专辑墙、封面和锁屏上原样显形。整理得糙,手机上就是一堆"未知艺术家"和灰色占位图。
| 维度 | 合格标准 | 常见毛病 |
|---|---|---|
| 文件名 | 歌手 - 歌名.mp3 |
顺序颠倒、无分隔、带下划线、/ ? 等非法字符 |
| 标签 | 标题/歌手与文件名一致 | 空标签、写反、乱码 |
| 专辑 | 已填(实在匹配不上才留空) | 空专辑、张冠李戴 |
| 封面 | 有图、最短边 ≥ 300px、无广告水印 | 无图、小图、通用合辑图、带 App logo |
| 标签精简 | 只留 4 帧:标题 / 歌手 / 专辑 / 封面 | 歌词、编码信息、风格等冗余帧 |
| 音频 | ≥ 192kbps、≥ 44100Hz | 128k 低码率、32000Hz |
| 体积 | 单文件 < 13MB | 无损或长曲目爆体积 |
| 旧标签 | 无 ID3v1 尾巴 | 文件末尾残留 TAG 块 |
四帧原则值得单独说:一首离线听的歌,只需要标题、歌手、专辑、封面。歌词(USLT)、编码软件(TSSE)、风格(TCON)、播放次数这些,播放器用不上,只会让文件变大、让某些车机/播放器识别异常。见到就删,别舍不得。
⚠️ 但这条原则和 APlayer 的歌词功能有冲突,第四章会专门讲怎么取舍。别急着照搬。
另一个我坚持的判断:专辑名宁可留空,也不要填错。填错的专辑会污染后续的封面匹配和按专辑浏览,比空着更难收拾。
二、方法论:一条流水线,四个角色 + 一个终点
补全专辑 → 校准封面 → 全库验收 → 按报告定点修复 → 再验收 → 发布目录 → APlayer 播放
几个比代码更重要的设计决定:
- 验收脚本只报告,不修复。它是质量门禁,职责单一。把"检查"和"修改"混在一起,出问题时你分不清是判断错了还是改错了。
- 每一步都产出日志 CSV:专辑补全日志、封面校准日志、验收报告。可追溯、可重跑、可回退。
- 断点续传是刚需,不是优化。几百首歌要联网查,接口会限流、会超时、会中途断。每 10~20 条落一次盘,重跑自动跳过已完成的,这能救命。
- 重复检测只列不删。删文件不可逆,交给人工确认。
- 有损操作必留退路。FLAC 转 MP3 后原文件移进备份目录,不硬删。
- 整理目录 ≠ 发布目录。整理过程会生成备份、日志、临时文件,直接整盘丢给播放器一定会出乱子。这一步在第四章展开。
三、技巧总结
3.1 命名与标签:让文件名成为唯一真相
把 歌手 - 歌名 定为唯一命名规范后,文件名和标签可以互相校验——只要比对 TIT2/TPE1 是否等于文件名拆出的两段,标签错没错一眼看穿。这比逐条打开看标签高效得多。
- 隐藏字符:文件名里的 NBSP(不换行空格)、零宽字符、首尾空格,肉眼完全看不出来,但会导致改名失败、搜索不到。用 Unicode 类别(Cf/Cc/Co)去扫,别只查空格。
- 归一化匹配:比对歌名时先转小写、去标点、剥离括号内容,再把
remix / live / 伴奏 / dj版 / feat等版本后缀去掉。否则"晴天"和"晴天(Live)"永远匹配不上。 - 双向包含判断:歌手名比对用"互相包含"而不是全等,能兼容"周杰伦"和"周杰伦/费玉清"这类合唱写法。
- 统一用 ID3v2.3 + UTF-8 保存(mutagen 里
encoding=3、v2_version=3)。v2.4 在部分老播放器上读不出来,Android 端的标签库读 v2.3 也最稳。
3.2 专辑补全:查得到,更要判得准
- 双源兜底:QQ 音乐覆盖广、返回结构规整,作主力;网易云覆盖面不同,作兜底。单一源必然有查不到的歌。
- 双查询:先按"歌手 + 完整歌名"查,没命中再按"歌手 + 剥掉版本后缀的歌名"查一次,命中率能提升一大截。
- 匹配要严:歌手名和歌名都要对上才采纳,否则网络翻唱、同名歌曲会把专辑名带歪。宁可漏,不可错。
- 官方名不等于好用名:有些平台返回的"专辑名"其实是打包名(比如《把酒倒满》返回的是"李晓杰 把酒倒满",把歌手名也塞了进去)。同一歌手多首歌都挂这种名字时,适当简化成真正的专辑名,保持库内一致。
- 实在匹配不上就留空。网络翻唱、现场版、地下 Remix 本来就没有正式专辑,强行挂一个合辑名反而是污染。
3.3 封面:先比对,再决定换不换
- 不要无脑覆盖。用感知哈希(dhash)算现有封面和官方封面的汉明距离:距离很小 = 同一张图(只是压缩差异),保留;距离明显大 = 不是一张图,替换。实测阈值取 8 比较合适,建议先小批量试跑看分布再调。
- 封面来源:QQ 官方专辑图有固定地址模板,拼上专辑 mid 就能拿 500×500 或 800×800 的图;网易则用歌曲详情接口取
picUrl。下载时同样要带Referer。 - "一堆歌共用同一张图"是危险信号。很多下载器给没有封面的歌塞同一张通用图或歌手合辑图,看到这种基本可判定错配。
- 视觉核查不能省。脚本判断不了"这张图有没有水印/logo/是不是本人"。把待疑封面拼成一张联系表(contact sheet),一次看几十张,比逐张打开快十倍。
- 别把官方设计误判成广告。《寻找周杰伦》EP 封面上有一串像乱码的字
vmp6513 5.ru,6xjp6,第一反应以为是盗版水印,查了资料才知道是官方设计的注音键位彩蛋。拿不准先查证,别急着删。 - 官方图也可能不好看。DJ 版/Remix 版官方封面常带厂牌 logo,这时退一步用原唱版本的同名专辑图,视觉更干净统一。
3.4 格式与体积:转码不是"能播就行"
FLAC 转 MP3,核心就一行命令,参数含义比代码重要:
ffmpeg -i input.flac -map_metadata -1 -codec:a libmp3lame \
-b:a 320k -ar 44100 -ac 2 -id3v2_version 3 output.mp3
-map_metadata -1必须加:不把源文件里乱七八糟的标签带进新文件,转完再干净地重写四帧。- 320k 不是万能解。长曲目(5 分钟以上)很容易突破 13MB 上限。做法:先按 320k 转,超了就降到 192k 重转一次。
- 采样率统一 44100、双声道,避免车机、老播放器、手机解码器挑食。
- 升码率是自欺欺人。128k 源文件转成 320k,体积变大、验收过了,音质一点没变好。只能换源,不能"修合格"。
3.5 乱码:识别比修复更需要分寸
老文件常见 ÃÂ¥... 这种编码错位,本质是 GBK 字节被按 Latin-1 解读了。修复思路很短:
cand = old.encode('latin-1').decode('gbk') # 逐个试 cp437 / cp1252 / latin-1
# 能解出连续汉字,就判定为乱码并写回
但判定规则一定要留白名单:Cécile Corbel、DAISHI DANCE 里的 ç、é 是正常法语字符,不是乱码。我第一版规则太宽,把它标成"标题乱码",差点手滑改坏。
另外别忘了 ID3v1 尾巴:文件末尾 128 字节以 TAG 开头的旧标签块,直接截掉即可,否则新旧标签打架,手机端可能显示两套信息。
3.6 重复检测:列出来,别删
按"归一化歌手 + 归一化歌名"分组,同组多于一首就列进报告。但时长是最好的辨伪工具:
- 《春庭雪》两个同名文件,240.9s 和 423.0s —— 明显不同录音,不是重复;
- 原曲版 vs DJ 版、Live 版、伴奏版 —— 不同版本,用户可能都想留;
- 真重复反而常伴随"一个 128k 无封面、一个 320k 带封面"的特征,一眼能认出来。
结论:脚本负责提醒,删除永远由人决定。
四、把整理好的库交给 APlayer
这一章是整理闭环的终点:前面所有规范,最终都是为了在播放器里"长"成好看的样子。
4.1 先认清它是什么
rRemix/APlayer 是一款 Android 本地音乐播放器(GPL v3,包名 remix.myplayer),不是 Windows 桌面软件。用 Jetpack Compose 重写的分支也在仓库里。
所以完整链路是:电脑上整理 → 把成品同步到手机 → 手机装 APlayer 播放。Windows 上装不了这个 App,别在 PC 上找安装包白费劲。
它的能力和我们的整理目标几乎一一对上:可配置首页标签(歌曲 / 艺术家 / 专辑 / 文件夹 / 播放列表)、自动或手动扫描指定目录、内置标签编辑器、桌面歌词与锁屏控制、均衡器与睡眠定时;compose 分支还支持 WebDAV 流媒体、内嵌歌词与逐字歌词。
4.2 安装(三选一)
| 渠道 | 适用情况 | 说明 |
|---|---|---|
| GitHub Releases APK | 国内最实用 | 打开仓库的 Releases,用 Latest 稳定版(当前 v2.1.3.1,2026-11-12 发布,新增 APE 支持),在 Assets 里挑与手机架构匹配的 APK(现代安卓一般选 arm64-v8a,不确定就选 universal) |
| Google Play | 有 Play 环境 | 搜 remix.myplayer |
| IzzyOnDroid / F-Droid | 偏好开源市场 | 仓库 README 里有官方徽章链接 |
注意三点:
- 侧载 APK 需要临时开启「允许安装未知来源应用」,装完可以关回去。
- 仓库还有
nightly预发布构建(每日自动打包)和 compose 重写分支,尝鲜可以,日常听歌建议老老实实用 Latest 稳定版。 - 首次启动要授予音频/媒体读取权限(Android 13+ 是「音乐和音频」权限),并允许它访问你放音乐的目录。
4.3 只同步"合格品":建一个发布目录
这是最容易被忽略、也最容易翻车的一步。整理目录里除了成品 MP3,还有一堆过程产物:
_原件备份_FLAC/ ← 无损备份,别进手机
_cover_fix/ ← 换下来的旧封面
__flac_tmp_*.mp3 ← 转码临时文件
*.csv 日志 ← 验收报告、校准日志
直接整盘拷贝到手机,播放器会把 FLAC 备份和成品 MP3 一起扫出来,凭空多出几十首"重复",还可能在部分机型上播不了 FLAC。
正确做法:另建一个干净的发布目录,只放验收合格的 .mp3,例如 W:/_发布_APlayer/。同步和扫描都只针对这个目录。这其实也是「以终为始」——整理的最后一步本来就该是"交付物打包",而不是"把工作目录丢出去"。
4.4 三种把音乐送到手机的方式
- USB 拷贝(最省事):把发布目录整个拷进手机存储的
Music/下,然后在 APlayer 里手动扫描该文件夹。 - 局域网 WebDAV(最贴合"整理一次、长期用"):compose 版 APlayer 支持 WebDAV 源,可在电脑上把发布目录开成一个 WebDAV 服务,手机连同一 WiFi 直接流式播放,以后新增歌曲只需重跑验收脚本、刷新一下即可,不用反复拷贝。用
wsgidav之类工具即可开服务(不同版本参数名略有差异,以该工具--help为准),记得在防火墙放行端口、仅限局域网匿名访问,别暴露到公网。 - 网盘 / 同步盘:方便但会产生本地缓存副本,等于占两份空间,且元数据有时被同步工具改写,优先级最低。
进 APlayer 后建议用手动扫描指定目录,而不是让它全盘自动扫描——后者会把通知音、录音、微信语音、缓存音频一起扫进来,库瞬间变脏。
4.5 整理的每一项规范,在 APlayer 里长什么样
这张表是前后两半教程的接缝,也解释了前面那些标准为什么值得较真:
| 整理时做的事 | APlayer 里的直接效果 |
|---|---|
歌手 - 歌名 命名 |
「文件夹」视图和歌曲列表排序干净;万一标签读不出,文件名还能让人看懂 |
TPE1 歌手准确 |
「艺术家」标签页正确归类,不会冒出一堆"未知艺术家"或一人拆成三份 |
TALB 专辑补全 |
「专辑」标签页聚合成墙,不会每首歌各占一个"单曲专辑" |
APIC 内嵌封面 |
专辑封面墙、播放页大图、锁屏、桌面小组件全靠它;不依赖联网 |
| 封面 ≥ 300px | 播放页和锁屏放大不糊、不出现马赛克 |
| 统一 ID3v2.3 + UTF-8 | 安卓端标签库读得稳,中文不乱码、不显示方块 |
| 删冗余帧 / ID3v1 | 新旧标签不打架,不会出现"标题显示两套"的怪事 |
| 体积 < 13MB、192~320k | 手机存得下、拷贝快、蓝牙听感够用 |
| 全库统一 MP3 | 避开部分机型 FLAC 解码异常(APlayer 从 v2.1.0.0 起才提供 ffmpeg 解码选项用于兜底) |
| 重复只列不删 | 手机歌单里不会滑两屏都是同一首 |
4.6 三个设置上的冲突,务必提前处理
① 关掉「自动下载专辑/艺术家封面」
APlayer 有从网易、LastFM 自动补全封面的功能,对没整理过的库是救星,对我们却是破坏:它会用网络图覆盖你花几轮校准好的官方封面,还可能给网络歌手配上一张别人的照片。整理过的库请关掉它,让 APIC 说话。
② 歌词:四帧原则和播放器需求正面撞上了
我们在第一章把 USLT(内嵌歌词)当冗余帧删了,而 APlayer 的歌词来源恰好有三条:内嵌、同名 .lrc 本地文件、在线搜索(网易/酷狗)。于是:
- 内嵌歌词 → 已被我们自己删掉;
- 在线歌词 → 要联网,而且库里偏偏有不少网络翻唱、DJ 版、地下 Remix,在线库基本搜不到;
- 本地
.lrc→ 整理时没生成。
如果你很在意手机上的歌词体验,二选一:
- 方案 A(推荐,改动最小):把验收脚本的保留帧集合从 4 帧放宽为 5 帧,加入
USLT,只对确实带内嵌歌词的文件放行,其余仍按四帧清理; - 方案 B:保持四帧,靠 APlayer 的在线歌词兜底,接受冷门曲目没词。
这不是"哪个更对",而是整理目标要跟着使用场景走——如果曲库只在电脑上听,四帧原则没问题;要搬到手机看词,标准就得让步。定标准之前先想清楚终点在哪。
③ 别把 FLAC 备份当成"高音质福利"一起拷过去
.flac 留一份在电脑备份目录就够了。手机上放无损既占空间,又可能触发解码兼容问题;真要在手机听无损,单独放一个明确标注的目录,别和成品 MP3 混在一起被扫成重复。
4.7 用播放器反向验收:比 CSV 更直观
整理做完后,打开 APlayer 花两分钟扫一眼,能发现脚本发现不了的问题:
- 进「专辑」页:如果冒出一堆只有一首歌的专辑,说明
TALB补得不对或过细; - 进「艺术家」页:同一个歌手出现两条,多半是全角/半角、繁简体或多余空格没统一;
- 看封面墙:大片重复的同一张图 = 错配没清干净;成片灰色占位 =
APIC没写进去或格式不被识别; - 随便点一首看播放页大图糊不糊,这是 ≥300px 标准最直接的检验;
- 搜一首带括号版本号的歌,看列表里显示是否和文件名一致。
脚本验的是"字段合不合规",播放器验的是"人看着顺不顺眼"。两个都过,才算真整理完了。
五、踩坑实录
按「现象 → 真相 → 对策」记,每条都耗过时间:
1. QQ 接口返回 code: 2001,以为接口挂了 真相:请求头缺 Referer: https://y.qq.com/,被反爬拦了。网易云同理要带 Referer: https://music.163.com。补上 header 立刻正常。
2. 查着查着突然全部返回空 真相:触发限流。并发开太高、请求太密都会中招。对策:并发控制在 5 左右、每次请求后停 0.40.8 秒、失败重试 12 次、大库分批跑并断点续传。别指望一口气跑完 600 首。
3. 封面下成了完全不相干的专辑 真相:从搜索结果取专辑 mid 时循环漏了 break,拿了列表最后一个而不是第一个命中项。对策:取第一个命中即停,重点歌曲再做人工二次核验。
4. 转码后体积还是超 13MB 真相:降到 192k 时脚本分支里忘了重新拼 ffmpeg 命令,实际仍按 320k 跑。对策:码率变更必须在分支内重建命令,且成功后立刻跳出。这类"降级分支没生效"很隐蔽,一定要看日志里的实际码率。
5. 网络歌手的歌挂上了周杰伦、张靓颖的封面 真相:靠歌名模糊匹配补图,把同名歌曲的专辑图错配过来(阴霾娃娃三首中招)。对策:查不到可靠官方图的歌手,换本人形象照或中性图,比"看着像封面其实是别人"强。
6. 文件名含隐形 NBSP、标签歌手和歌名写反 真相:某 Remix 文件从下载器出来标签方向就是错的。对策:别试图修字符串,直接按规范重写 TPE1/TIT2 并整体重命名。
7. 把官方封面彩蛋当广告删了 见《寻找周杰伦》EP。教训:视觉上"奇怪"不等于"错",动手前先查证。同理,有些封面全网都带发行方 logo(如某首带"QQ音乐·银河计划"),那是官方设计,保留即可。
8. 法语重音被判成乱码 见 3.5。乱码检测要有白名单,或收紧到"必须能反解出连续汉字"才判定。
9. 专辑名是"歌手 + 歌名"的打包名 《把酒倒满》返回 李晓杰 把酒倒满,直接写进 TALB 会让按专辑浏览多出奇怪分类。对策:识别并简化,顺手排查同批有没有顺序颠倒的命名。
10. 128k 低码率歌想靠转码"合格" 真相:升码率不增加信息量。对策:诚实标为遗留,等好源再替换。我最后留了 28 首 128k 没动,比"看起来全绿"更有价值。
11. 无损源删了回不来 FLAC→MP3 不可逆。所有转码都把原文件移进 _原件备份_FLAC/,而不是 os.remove。哪怕用户说"删掉原文件",也先留后路。
12. 整盘拷到手机,APlayer 扫出一堆重复和播不了的歌 真相:把 _原件备份_FLAC/、转码临时文件、CSV 日志一起拷了过去,FLAC 和成品 MP3 双双入库。对策:只同步干净的发布目录 + 手动指定扫描目录(见 4.3 / 4.4)。
13. 精心校准的封面在手机上全变了样 真相:APlayer 的「自动下载专辑/艺术家封面」开着,联网后用第三方图源覆盖了内嵌 APIC。对策:整理过的库关掉自动补全封面(见 4.6)。这条最气人——等于前面的封面校准白做。
14. 手机上大部分歌没有歌词 真相:四帧原则把 USLT 删干净了,而库里网络翻唱/DJ 版在线歌词又搜不到。对策:按 4.6 的方案 A 或 B 提前定好,别等搬到手机才发现。
六、几条判断心法
- 以终为始:定标准之前先想清楚在哪听、用什么播。只存电脑和要搬到手机看歌词,答案不一样(见 4.6 ②)。
- 宁缺毋错:专辑、封面、匹配结果,拿不准就留空,不要硬填。
- 宁留白不强凑:报告有 29 首不合格并不可耻,为了 100% 去"修合格"才是自欺欺人。
- 只报告,不自动删:任何不可逆操作都留人工确认环节。
- 有损必留退路:转码、覆盖、批量改名,先备份再动手。
- 视觉核查不可省:脚本算得出尺寸和哈希,算不出"这张图是不是广告""长得像不像本人"。播放器界面就是最后一轮视觉核查。
- 让日志说话:每一步留 CSV,出问题能回放,改错了能回退。
- 先小批量试跑:新规则、新阈值先拿 20 首验证,看输出分布再全量推。
- 把交付物和工作目录分开:整理过程产生的备份、日志、临时文件,永远不要混进最终要听的库。
七、成果与遗留
最终曲库 959 首 MP3,930 首完全合格(约 97%)。剩下 29 首一款没硬修:
- 28 首 128kbps 低码率 —— 需要更好的音源(会员 Cookie),转码解决不了;
- 1 首采样率 32000Hz —— 音源固有;
- 少量"原曲 / DJ 版"同名组 —— 不同版本,交人工决定去留;
- 另有 1 条是法语重音的乱码误报,规则加白名单后排除。
这些都属于脚本无权替你决定的事。把它们明明白白列在报告里,比一个漂亮的 100% 更可信。
同步到手机后,APlayer 的专辑页、艺术家页和封面墙把这些数字变成了肉眼可见的东西——那才是整理真正完成的时刻。
附一:脚本清单(只列职责,不贴代码)
| 脚本 | 职责 | 关键点 |
|---|---|---|
album_query*.py |
补全空专辑 | QQ 主力 + 网易兜底,多轮查询,断点续传 |
cover_batch.py |
校准封面 | dhash + 汉明距离 > 8 才替换,输出校准日志 |
final_audit.py |
全库验收 + FLAC 自动转码 | 逐项出 CSV 报告;320k→超 13MB 降 192k |
fix_final.py |
按报告定点修复 | 删 ID3v1、清冗余帧、乱码反解、改名 |
import_flac.py |
单个 FLAC 入库 | 转码 + 四帧 + 官方封面 + 规范命名 |
依赖四个:mutagen(标签)、Pillow(图像/哈希)、requests(接口)、imageio-ffmpeg(自带 ffmpeg,免安装)。目标目录写成脚本里的 ROOT,音乐文件平铺即可。
附二:播放端信息卡
| 项目 | 内容 |
|---|---|
| 名称 | APlayer(安卓本地音乐播放器) |
| 仓库 | https://github.com/rRemix/APlayer |
| 协议 / 包名 | GPL v3 / remix.myplayer |
| 平台 | Android(Windows 上无法安装) |
| 稳定版 | v2.1.3.1(2026-11-12,新增 APE 支持) |
| 安装渠道 | GitHub Releases APK / Google Play / IzzyOnDroid |
| 与本文相关的能力 | 按歌曲·艺术家·专辑·文件夹·播放列表浏览、手动扫描指定目录、内嵌/本地/在线歌词、自动补全封面、内置标签编辑器、桌面歌词与锁屏、均衡器、睡眠定时 |
| compose 分支附加 | WebDAV 流媒体、内嵌/逐字歌词、浅色·深色·AMOLED 主题、封面自动下载 |
| 建议设置 | 关闭"自动下载专辑/艺术家封面";手动指定发布目录扫描;按歌词需求决定是否保留 USLT |
小结
整理音乐库真正难的地方,从来不是"怎么用 Python 写 ID3 标签",而是在几百首歌里,一次次判断"这张封面该不该换""这个专辑名算不算对""这首同名文件是不是重复"。
而把 APlayer 接进来之后,多了一层更朴素的检验:打开手机,专辑墙是不是整齐、封面是不是对的、点开有没有词。脚本负责把标准变成可检查的事实,播放器负责告诉你这些标准值不值。
整理 → 交付 → 播放 → 反向验收,这个闭环跑通了,曲库才是真干净,而不只是报告里一片绿。
这不是一份代码教程。代码只占很小一部分,真正值钱的是怎么定标准、怎么判断"该不该改"、以及哪些坑会让你白干一整晚。 本文基于一次真实整理:959 首 MP3,最终 930 首完全合格,剩下 29 首诚实地留白。
一、先想清楚:整理到底在整理什么
大多数人一上来就问"用什么脚本批量改标签"。但真正的问题不是改,是没有标准。
一堆歌之所以乱,是因为从来没人定义过"什么样算合格"。所以第一步不是写代码,是把标准钉死,让它变成可量化、可复查的东西:
| 维度 | 合格标准 | 常见毛病 |
|---|---|---|
| 文件名 | 歌手 - 歌名.mp3 |
顺序颠倒、无分隔、带下划线、/ ? 等非法字符 |
| 标签 | 标题/歌手与文件名一致 | 空标签、写反、乱码 |
| 专辑 | 已填(实在匹配不上才留空) | 空专辑、张冠李戴 |
| 封面 | 有图、最短边 ≥ 300px、无广告水印 | 无图、小图、通用合辑图、带 App logo |
| 标签精简 | 只留 4 帧:标题 / 歌手 / 专辑 / 封面 | 歌词、编码信息、风格等冗余帧 |
| 音频 | ≥ 192kbps、≥ 44100Hz | 128k 低码率、32000Hz |
| 体积 | 单文件 < 13MB | 无损或长曲目爆体积 |
| 旧标签 | 无 ID3v1 尾巴 | 文件末尾残留 TAG 块 |
四帧原则值得单独说:一首离线听的歌,只需要标题、歌手、专辑、封面。歌词(USLT)、编码软件(TSSE)、风格(TCON)、播放次数这些东西,播放器用不上,只会让文件变大、让某些车机/播放器识别异常。见到就删,别舍不得。
一个我坚持的判断:专辑名宁可留空,也不要填错。填错的专辑会污染后续的封面匹配和按专辑浏览,比空着更难收拾。
二、方法论:一条流水线,四个角色
整理不是一个大脚本一把梭,而是四个各司其职的步骤:
补全专辑 → 校准封面 → 全库验收 → 按报告定点修复 → 再验收
几个比代码更重要的设计决定:
- 验收脚本只报告,不修复。它是质量门禁,职责单一。把"检查"和"修改"混在一起,出了问题你分不清是判断错了还是改错了。
- 每一步都产出日志 CSV:专辑补全日志、封面校准日志、验收报告。可追溯、可重跑、可回退,出错时能精确定位到哪一步。
- 断点续传是刚需,不是优化。几百首歌要联网查,接口会限流、会超时、会中途断。每 10~20 条落一次盘,重跑自动跳过已完成的,这能救命。
- 重复检测只列不删。删文件是不可逆操作,交给人工确认。
- 有损操作必留退路。FLAC 转 MP3 后原文件移进备份目录,不硬删。
三、技巧总结
3.1 命名与标签:让文件名成为唯一真相
把 歌手 - 歌名 定为唯一命名规范后,文件名和标签可以互相校验——验收时只要比对 TIT2/TPE1 是否等于文件名拆出的两段,标签错没错一眼看穿。这比逐条打开看标签高效得多。
几个容易忽略的细节:
- 隐藏字符:文件名里的 NBSP(不换行空格)、零宽字符、首尾空格,肉眼完全看不出来,但会导致改名失败、搜索不到。用 Unicode 类别(Cf/Cc/Co)去扫,别只查空格。
- 归一化匹配:比对歌名时先转小写、去标点、剥离括号内容,再把
remix / live / 伴奏 / dj版 / feat等版本后缀去掉。否则"晴天"和"晴天(Live)"永远匹配不上。 - 双向包含判断:歌手名比对用"互相包含"而不是全等,能兼容"周杰伦"和"周杰伦/费玉清"这类合唱写法。
- 统一用 ID3v2.3 + UTF-8 保存(mutagen 里
encoding=3、v2_version=3)。v2.4 在部分老播放器上读不出来,这是兼容性最稳的组合。
3.2 专辑补全:查得到,更要判得准
- 双源兜底:QQ 音乐覆盖广、返回结构规整,作主力;网易云覆盖面不同,作兜底。单一源必然有查不到的歌。
- 双查询:先按"歌手 + 完整歌名"查,没命中再按"歌手 + 剥掉版本后缀的歌名"查一次。命中率能提升一大截。
- 匹配要严:歌手名和歌名都要对上才采纳,否则网络翻唱、同名歌曲会把专辑名带歪。这一步宁可漏,不可错。
- 官方名不等于好用名:有些平台的"专辑名"其实是打包名(比如《把酒倒满》返回的是"李晓杰 把酒倒满",把歌手名也塞了进去)。同一歌手多首歌都挂这种名字时,适当简化成真正的专辑名,保持库内一致。
- 实在匹配不上就留空。网络翻唱、现场版、地下 Remix 本来就没有正式专辑,强行挂一个合辑名反而是污染。
3.3 封面:先比对,再决定换不换
这是最容易翻车的一环,也是技巧含量最高的一环。
- 不要无脑覆盖。用感知哈希(dhash)算现有封面和官方封面的汉明距离:距离很小 = 同一张图(只是压缩差异),保留;距离明显大 = 不是一张图,替换。实测阈值取 8 比较合适,建议先小批量试跑,看距离分布再调。
- 封面来源:QQ 官方专辑图有固定地址模板,拼上专辑 mid 就能拿到 500×500 或 800×800 的图;网易则用歌曲详情接口取
picUrl。下载时同样要带Referer。 - "一堆歌共用同一张图"是危险信号。很多下载器给没有封面的歌塞同一张通用图或歌手合辑图,看到这种基本可以判定错配。
- 视觉核查不能省。脚本判断不了"这张图有没有水印/logo/是不是本人"。把待疑封面拼成一张联系表(contact sheet),一次看几十张,比逐张打开快十倍,也更容易发现"这张怎么和那张一模一样"。
- 别把官方设计误判成广告。《寻找周杰伦》EP 封面上有一串像乱码的字
vmp6513 5.ru,6xjp6,第一反应以为是盗版站点水印,查了资料才知道是官方设计的注音键位彩蛋。拿不准的时候先查证,别急着删。 - 官方图也可能不好看。DJ 版/Remix 版的官方封面常带厂牌 logo。这种情况下退一步用原唱版本的同名专辑图,视觉更干净统一。
3.4 格式与体积:转码不是"能播就行"
FLAC 转 MP3,核心命令就一行,参数含义比代码重要:
ffmpeg -i input.flac -map_metadata -1 -codec:a libmp3lame \
-b:a 320k -ar 44100 -ac 2 -id3v2_version 3 output.mp3
-map_metadata -1必须加:不把源文件里那些乱七八糟的标签带进新文件,转完再干净地重写四帧。- 320k 不是万能解。长曲目(5 分钟以上)用 320k 很容易突破 13MB 上限。做法:先按 320k 转,转完看体积,超了就降到 192k 重转一次。
- 采样率统一 44100、双声道,避免车机、老播放器挑食。
- 升码率是自欺欺人。128k 的源文件转成 320k,体积变大了、验收过了,但音质一点没变好。这种只能换源,不能"修合格"。
3.5 乱码:识别比修复更需要分寸
老文件常见 ÃÂ¥... 这种编码错位,本质是 GBK 字节被按 Latin-1 解读了。修复思路很短:
cand = old.encode('latin-1').decode('gbk') # 逐个试 cp437 / cp1252 / latin-1
# 能解出连续汉字,就判定为乱码并写回
但判定规则一定要留白名单:Cécile Corbel、DAISHI DANCE 里的 ç、é 是正常法语字符,不是乱码。我第一版规则太宽,把它标成了"标题乱码",差点手滑改坏。外语人名、乐队名集中的库尤其要注意。
另外别忘了 ID3v1 尾巴:文件末尾 128 字节以 TAG 开头的那块旧标签,直接截掉即可,否则新旧标签打架,播放器可能显示两套信息。
3.6 重复检测:列出来,别删
按"归一化歌手 + 归一化歌名"分组,同组多于一首就列进报告。但时长是最好的辨伪工具:
- 《春庭雪》两个同名文件,240.9s 和 423.0s —— 明显是不同录音,不是重复;
- 原曲版 vs DJ 版、Live 版、伴奏版 —— 是不同版本,用户可能都想留;
- 真重复反而往往伴随"一个 128k 无封面、一个 320k 带封面"的特征,一眼能认出来。
结论:脚本负责"提醒",删除永远由人决定。
四、踩坑实录
按"现象 → 真相 → 对策"记,这几条每条都耗过时间:
1. QQ 接口返回 code: 2001,以为接口挂了 真相:请求头缺 Referer: https://y.qq.com/,被反爬拦了。网易云同理要带 Referer: https://music.163.com。补上 header 立刻正常。
2. 查着查着突然全部返回空 真相:触发限流。并发开太高、请求太密都会中招。对策:并发控制在 5 左右、每次请求后停 0.40.8 秒、失败重试 12 次、大库分批跑并断点续传。别指望一口气跑完 600 首。
3. 封面下成了完全不相干的专辑 真相:从搜索结果里取专辑 mid 时循环漏了 break,拿了列表最后一个而不是第一个命中项,于是《北京欢迎你》《爱上未来的你》挂上了别的图。对策:取第一个命中即停,并且对少数重点歌曲做人工二次核验(用固定的官方 mid 校验)。
4. 转码后体积还是超 13MB 真相:降到 192k 时脚本分支里忘了重新拼 ffmpeg 命令,实际仍按 320k 跑,还重复执行了一次。对策:码率变更必须在循环内重建命令,且成功后立刻跳出。这类"降级分支没生效"的 bug 很隐蔽,一定要看输出日志里的实际码率。
5. 网络歌手的歌挂上了周杰伦、张靓颖的封面 真相:靠歌名模糊匹配补图,把同名/相近歌曲的专辑图错配过来了(阴霾娃娃三首中招)。对策:这类查不到可靠官方图的歌手,改换成歌手本人形象照或中性图,比"看起来像专辑封面但其实是别人"强得多。
6. 文件名含隐形 NBSP、标签歌手和歌名写反 真相:某 Remix 文件从下载器出来时标签方向就是错的,文件名还带了不可见空格。对策:不要试图"修字符串",直接按规范重写 TPE1/TIT2 并整体重命名,一步到位。
7. 把官方封面彩蛋当广告删了 见前文《寻找周杰伦》EP。教训:视觉上"奇怪"不等于"错",动手前先查证。同理,有些歌的封面全网都带发行方 logo(如某首带"QQ音乐·银河计划"),那是官方封面设计,不是水印,保留即可。
8. 法语重音被判成乱码 见 3.5。乱码检测规则要有白名单,或收紧到"必须能反解出连续汉字"才判定。
9. 专辑名是"歌手 + 歌名"的打包名 《把酒倒满》返回 李晓杰 把酒倒满,直接写进 TALB 会让按专辑浏览多出一个奇怪分类。对策:识别这类打包命名并简化,同时顺手排查同批是否存在顺序颠倒的命名(歌名-歌手)。
10. 128k 低码率歌想靠转码"合格" 真相:升码率不增加信息量。对策:诚实标为遗留,等有会员源再替换。我最后留了 28 首 128k 没动,比"看起来全绿"更有价值。
11. 无损源删了回不来 FLAC→MP3 不可逆。所有转码都把原文件移进 _原件备份_FLAC/,而不是 os.remove。哪怕用户说"删掉原文件",也先留后路——这是处理别人资料时该有的保守。
五、几条判断心法
把上面的坑抽象一下,其实就这几条:
- 宁缺毋错:专辑、封面、匹配结果,拿不准就留空,不要硬填。
- 宁留白不强凑:验收报告有 29 首不合格并不可耻,为了 100% 去"修合格"才是自欺欺人。
- 只报告,不自动删:任何不可逆操作都留人工确认环节。
- 有损必留退路:转码、覆盖、批量改名,先备份再动手。
- 视觉核查不可省:脚本算得出尺寸和哈希,算不出"这张图是不是广告""长得像不像本人"。
- 让日志说话:每一步留 CSV,出问题能回放,改错了能回退。
- 先小批量试跑:新规则、新阈值先拿 20 首验证,看输出分布再全量推。
六、成果与遗留
最终曲库 959 首 MP3,930 首完全合格(约 97%)。剩下 29 首我一款没硬修:
- 28 首 128kbps 低码率 —— 需要更好的音源(会员 Cookie),转码解决不了;
- 1 首采样率 32000Hz —— 音源固有;
- 少量"原曲 / DJ 版"同名组 —— 不同版本,交人工决定去留;
- 另有 1 条是法语重音的乱码误报,规则加白名单后排除。
这些都属于脚本无权替你决定的事。把它们明明白白列在报告里,比一个漂亮的 100% 更可信。
附:脚本清单(只列职责,不贴代码)
| 脚本 | 职责 | 关键点 |
|---|---|---|
album_query*.py |
补全空专辑 | QQ 主力 + 网易兜底,4 轮查询,断点续传 |
cover_batch.py |
校准封面 | dhash + 汉明距离 > 8 才替换,输出校准日志 |
final_audit.py |
全库验收 + FLAC 自动转码 | 逐项出 CSV 报告;320k→超 13MB 降 192k |
fix_final.py |
按报告定点修复 | 删 ID3v1、清冗余帧、乱码反解、改名 |
import_flac.py |
单个 FLAC 入库 | 转码 + 四帧 + 官方封面 + 规范命名 |
依赖就四个:mutagen(标签)、Pillow(图像/哈希)、requests(接口)、imageio-ffmpeg(自带 ffmpeg,免安装)。目标目录写成脚本里的 ROOT,音乐文件平铺即可。
小结
整理音乐库真正难的地方,从来不是"怎么用 Python 写 ID3 标签",而是在几百首歌里,一次次判断"这张封面该不该换""这个专辑名算不算对""这首同名文件是不是重复"。
脚本负责把标准变成可检查的事实,负责跑腿、负责留日志;判断留给人。把这条边界守住了,整理出来的库才是真干净,而不只是看起来绿。
一份能直接落地的「本地音乐库整理 + 自动验收」教程。
核心思路:先定验收规范,再写脚本批量修,最后用一个验收脚本兜底复查。
本文所有代码基于 Python,已在 950+ 首真实曲库上跑通,最终 930 首完全合格。
一、为什么要整理
下载、搬运、换设备……几年下来,音乐文件夹往往是这样的:
- 文件名五花八门:
audio_01.mp3、周杰伦-晴天.mp3、晴天-周杰伦(高清).mp3 - 标签残缺:没专辑、没封面,或者封面是某音乐 App 的水印图
- 格式混杂:FLAC 无损占着几十 GB,MP3 又夹着 128k 的低音质
- 重复文件:原曲版 + DJ 版 + 同名不同录音,肉眼难辨
- 隐藏雷点:ID3v1 残留、UTF-8 乱码、OST 残留标记、广告图当封面
手动改几百上千首不现实。用脚本把规范固化下来,让"合格"变成可量化、可复查的标准,才是正解。
二、先定规范:验收清单
动手前先明确"什么样算合格"。我把标准定成下面这张表,后面所有脚本都围绕它写:
| 维度 | 合格标准 | 不合格表现 |
|---|---|---|
| 文件名 | 歌手 - 歌名.mp3(空格+短横+空格) |
无分隔、顺序颠倒、含下划线、/ ? 等非法字符 |
| 标题/歌手标签 | TIT2 / TPE1 与文件名一致 |
标签为空、与文件名不符、乱码 |
| 专辑 | TALB 已填(实在匹配不上才留空) |
空专辑、错配到其他歌手 |
| 封面 | 有 APIC,最短边 ≥ 300px,格式 JPEG/PNG |
无封面、封面过小、封面损坏、挂了广告/水印图 |
| 标签精简 | 仅保留 TIT2 / TPE1 / TALB / APIC 四帧 |
残留 USLT/TSSE/TCON 等冗余帧 |
| 音频质量 | 码率 ≥ 192kbps,采样率 ≥ 44100Hz | 128k 低码率、32000Hz 低采样 |
| 体积 | 单文件 < 13MB | 超过阈值 |
| 旧标签 | 无 ID3v1 残留 | 文件尾仍带 TAG 块 |
四帧原则:一首本地歌曲只需要"标题、歌手、专辑、封面"。歌词、编码信息、风格标签、播放计数这些在线元数据,离线听歌用不上,反而会让文件变大、播放器识别异常。统统删掉。
三、环境与依赖
pip install mutagen pillow requests imageio-ffmpeg
mutagen:读写 MP3(ID3)和 FLAC 标签,支持帧级精确操作Pillow:封面尺寸校验、格式转换、感知哈希比对requests:调用 QQ 音乐 / 网易云接口补全专辑和封面imageio-ffmpeg:自带一个 ffmpeg 可执行文件,免去单独安装
代码里的
ROOT = 'W:/'是整理目标目录,改成你自己的音乐文件夹即可。所有脚本都假设音乐文件直接平铺在该目录下(无多级子目录)。
四、核心一:全库验收脚本 final_audit.py
这是整个流程的"总闸门"。它做两件事:
- 扫描前自动转码:发现目录里的
.flac,先转成标准 MP3 再一起验收(见下一节)。 - 逐首验收:按第二节的清单检查每首 MP3,输出一份 CSV 报告 + 控制台汇总。
4.1 设计要点
- 命名校验:正则
^(.+?) - (.+)\.mp3$拆解歌手与歌名,再和标签比对。 - 非法字符 / 隐藏字符:
\ / : * ? " < > |以及不可见控制符(Cc/Cf 类)都算不合格。 - 乱码识别:用一套启发式规则(特殊拉丁字符 +
cp437 → gbk反解能解出汉字)判断标签是否编码错位。 - 封面核查:用 PIL 打开
APIC数据,检查尺寸、格式、是否可解析。 - 冗余帧:凡是
TIT2/TPE1/TALB/APIC之外的 ID3 帧都标记为"冗余"。 - ID3v1 残留:读文件最后 128 字节,看是否以
TAG开头。 - 重复检测:按"歌手+歌名"归一化后分组,同名多于一首列出供人工确认。
4.2 完整代码
# -*- coding: utf-8 -*-
"""全库最终验收:逐项核对历史全部要求(含 FLAC 自动转码)"""
import os, re, io, csv, unicodedata, hashlib, shutil, subprocess
from collections import defaultdict, Counter
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, ID3NoHeaderError, TIT2, TPE1, TALB, APIC
from mutagen.flac import FLAC
from PIL import Image
ROOT = 'W:/'
OUT = 'W:/最终验收报告.csv'
FLAC_BAK = os.path.join(ROOT, '_原件备份_FLAC')
NAME_RE = re.compile(r'^(.+?) - (.+)\.mp3$', re.I)
BAD_CHARS = re.compile(r'[\\/:*?"<>|\t\r\n]')
MOJI_HINT = re.compile(r'[ÃÂåäæçèéêëìíîïðñòóôõöøùúûüýþ]')
KEEP = {'TIT2', 'TPE1', 'TALB', 'APIC'}
def has_hidden(s):
if s != s.strip(): return True
if '\u00a0' in s: return True
for ch in s:
if unicodedata.category(ch) in ('Cf', 'Cc', 'Co', 'Cn'):
return True
return False
def mojibake(s):
"""判断是否为编码错位文本"""
if not s: return False
if MOJI_HINT.search(s): return True
try:
fixed = s.encode('cp437').decode('gbk')
if fixed != s and re.search(r'[\u4e00-\u9fff]', fixed):
return True
except Exception:
pass
return False
def norm_title(t):
t = t.lower().strip()
t = re.sub(r'\(.*?\)|(.*?)|\[.*?\]|【.*?】', '', t)
t = re.sub(r'\s*(feat\.?|ft\.?|remix|live|伴奏|dj版)\s*.*$', '', t)
t = re.sub(r'[^\w\u4e00-\u9fff]', '', t)
return t
def norm_artist(a):
a = a.lower()
a = re.sub(r'[^\w\u4e00-\u9fff]', '', a)
return a
def flac_cover_data(fl):
"""取 FLAC 第一张图,规整为 JPEG 字节"""
if not fl.pictures:
return None
try:
im = Image.open(io.BytesIO(fl.pictures[0].data)).convert('RGB')
buf = io.BytesIO(); im.save(buf, 'JPEG', quality=92)
return buf.getvalue()
except Exception:
return None
def convert_one_flac(src, ff):
"""把单个 FLAC 转成 320k mp3(>13MB 时降为 192k),写标准标签+封面"""
fn = os.path.basename(src)
try:
fl = FLAC(src)
except Exception as e:
print(' [跳过] FLAC 解析失败:', fn, e); return None
tags = fl.tags or {}
g = lambda k: (tags.get(k) or [''])[0].strip()
title, artist, album = g('title'), g('artist'), g('album')
if not (title and artist):
mfn = re.match(r'^(.+?) - (.+)$', fn[:-5].strip())
if mfn:
artist = artist or mfn.group(1).strip()
title = title or mfn.group(2).strip()
title = title or fn[:-5]
artist = artist or '未知歌手'
out_name = BAD_CHARS.sub('_', '%s - %s.mp3' % (artist, title))
dst = os.path.join(ROOT, out_name)
tmp = os.path.join(ROOT, '__flac_tmp_%s.mp3' % hashlib.md5(fn.encode()).hexdigest()[:8])
br = 320
cmd = [ff, '-y', '-i', src, '-map_metadata', '-1', '-codec:a', 'libmp3lame',
'-b:a', '%dk' % br, '-ar', '44100', '-ac', '2', '-id3v2_version', '3', tmp]
r = subprocess.run(cmd, capture_output=True)
if r.returncode != 0:
print(' [跳过] ffmpeg 失败:', fn); return None
if os.path.getsize(tmp) / 1048576.0 > 13:
br = 192
cmd = [ff, '-y', '-i', src, '-map_metadata', '-1', '-codec:a', 'libmp3lame',
'-b:a', '%dk' % br, '-ar', '44100', '-ac', '2', '-id3v2_version', '3', tmp]
r = subprocess.run(cmd, capture_output=True)
if r.returncode != 0:
print(' [跳过] ffmpeg 失败:', fn); return None
try:
t = ID3(tmp)
except Exception:
t = ID3()
t.add(TIT2(encoding=3, text=title))
t.add(TPE1(encoding=3, text=artist))
if album:
t.add(TALB(encoding=3, text=album))
cov = flac_cover_data(fl)
if cov:
t.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=cov))
t.save(tmp, v2_version=3)
if os.path.exists(dst):
base2, ext = os.path.splitext(dst)
dst = base2 + '_2' + ext
shutil.move(tmp, dst)
os.makedirs(FLAC_BAK, exist_ok=True)
try:
shutil.move(src, os.path.join(FLAC_BAK, fn))
except Exception:
try: os.remove(src)
except Exception: pass
print(' [OK] %s -> %s (%.2fMB, %dk)' % (
fn, os.path.basename(dst), os.path.getsize(dst) / 1048576.0, br))
return dst
def convert_flac_files(ff):
flacs = [f for f in sorted(os.listdir(ROOT)) if f.lower().endswith('.flac')]
if not flacs:
return
print('发现 %d 个 FLAC,开始转码为 mp3 并移除原文件...' % len(flacs))
for fn in flacs:
convert_one_flac(os.path.join(ROOT, fn), ff)
print('FLAC 转码完成。')
ff = None
try:
import imageio_ffmpeg
ff = imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
ff = None
if ff:
convert_flac_files(ff)
else:
print('警告:未找到 ffmpeg,跳过 FLAC 转码。')
rows = []
files = [f for f in sorted(os.listdir(ROOT)) if f.lower().endswith('.mp3')]
print('files:', len(files))
groups = defaultdict(list)
for i, fn in enumerate(files, 1):
p = os.path.join(ROOT, fn)
issues = []
detail = []
stem = fn[:-4]
size_mb = os.path.getsize(p) / 1048576.0
if size_mb > 13:
issues.append('体积超标'); detail.append('%.1fMB' % size_mb)
m = NAME_RE.match(fn)
if not m:
issues.append('文件名格式')
else:
ar_fn, ti_fn = m.group(1).strip(), m.group(2).strip()
if BAD_CHARS.search(fn): issues.append('非法字符')
if has_hidden(stem): issues.append('隐藏字符')
if re.search(r'[\((\[【]\s*(ost|Ost|OST)\s*[\))\]】]|\s+-\s*(ost|OST)\s*$', stem):
issues.append('OST残留')
if re.search(r'_', stem): issues.append('下划线')
try:
tags = ID3(p)
except ID3NoHeaderError:
tags = None
t_title = t_art = t_alb = ''
has_cover = False
cover_ok = ''
extra = []
if tags is None:
issues.append('无ID3标签')
else:
t_title = str(tags.get('TIT2', ''))
t_art = str(tags.get('TPE1', ''))
t_alb = str(tags.get('TALB', ''))
for k in tags.keys():
if k.split(':')[0] not in KEEP:
extra.append(k)
if extra: issues.append('冗余帧%d' % len(extra))
aps = tags.getall('APIC')
if not aps:
issues.append('无封面')
else:
has_cover = True
ap = aps[0]
try:
im = Image.open(io.BytesIO(ap.data))
w, h = im.size
cover_ok = '%dx%d/%s/%dKB' % (w, h, im.format, len(ap.data)//1024)
if min(w, h) < 300: issues.append('封面过小')
if im.format not in ('JPEG', 'PNG'): issues.append('封面格式%s' % im.format)
except Exception:
issues.append('封面损坏')
cover_ok = 'unreadable'
for label, val in (('标题', t_title), ('歌手', t_art), ('专辑', t_alb)):
if val and mojibake(val):
issues.append('%s乱码' % label)
if not t_title: issues.append('缺标题')
if not t_art: issues.append('缺歌手')
if m:
if t_title.strip() != ti_fn:
issues.append('标题≠文件名'); detail.append('T[%s]≠F[%s]' % (t_title, ti_fn))
if t_art.strip() != ar_fn:
issues.append('歌手≠文件名'); detail.append('A[%s]≠F[%s]' % (t_art, ar_fn))
with open(p, 'rb') as f:
f.seek(-128, 2)
if f.read(3) == b'TAG':
issues.append('ID3v1残留')
br = sr = ch = 0
dur = 0
try:
au = MP3(p)
br = au.info.bitrate // 1000
sr = au.info.sample_rate
ch = au.info.channels
dur = au.info.length
if br < 192: issues.append('码率%d' % br)
if sr < 44100: issues.append('采样率%d' % sr)
except Exception as e:
issues.append('音频损坏')
if m:
groups[(norm_artist(ar_fn), norm_title(ti_fn))].append(fn)
rows.append([fn, ' '.join(issues), ' | '.join(detail), br, sr, ch,
round(dur, 1), round(size_mb, 2), cover_ok, len(extra), t_alb, t_art, t_title])
dups = {k: v for k, v in groups.items() if len(v) > 1}
with open(OUT, 'w', newline='', encoding='utf-8-sig') as f:
w = csv.writer(f)
w.writerow(['文件名', '问题项', '细节', '码率kbps', '采样率', '声道', '时长s', '大小MB', '封面', '冗余帧数', '专辑', '歌手标签', '标题标签'])
w.writerows(rows)
cnt = Counter()
clean = 0
for r in rows:
if not r[1]:
clean += 1
for x in r[1].split():
cnt[re.sub(r'\d+$', '', x)] += 1
print('=' * 50)
print('总数 %d 完全合格 %d 有问题 %d' % (len(rows), clean, len(rows) - clean))
for k, v in cnt.most_common():
print(' %-12s %d' % (k, v))
print('重复组 %d' % len(dups))
for k, v in dups.items():
print(' DUP:', ' || '.join(v))
print('报告:', OUT)
4.3 运行效果
总数 959 完全合格 930 有问题 29
码率192 28
标题乱码 1
采样率44100 1
重复组 4
报告: W:/最终验收报告.csv
报告 最终验收报告.csv 每行一首歌,列出"问题项""细节""码率""封面状态"等,可以直接按"问题项"列筛选批量处理。
五、核心二:FLAC 自动转码(内置在验收脚本里)
很多人库里混着 FLAC,体积大、很多播放器不友好。验收脚本在扫描前自动处理:
- 用
imageio-ffmpeg自带的 ffmpeg 转码为 MP3:libmp3lame,默认 320k,采样率 44100,双声道。 - 体积控制:若 320k 转出来仍 > 13MB,自动降到 192k 再转一次(长曲目用 320k 会爆体积)。
- 写入标准四帧标签:标题/歌手来自 FLAC 原标签,缺失时回退到"歌手 - 歌名"文件名;专辑原样保留;封面取 FLAC 内嵌图转成 JPEG 写入。
- 保守删除:转码成功后,原 FLAC 不直接删,而是移入
_原件备份_FLAC/目录。有损转换不可逆,保留无损源方便回退。
如果你确定要硬删,把脚本里
shutil.move(src, ...)改成os.remove(src)即可,但强烈不建议——无损源丢了就回不来了。
六、专辑信息补全 album_query*.py
几百首歌空着专辑名,手动填不现实。思路:QQ 音乐搜索为主,网易云兜底,断点续传防中断。
6.1 关键逻辑
- QQ 搜索接口:
https://u.y.qq.com/cgi-bin/musicu.fcg,方法DoSearchForQQMusicDesktop。必须带请求头Referer: https://y.qq.com/,否则返回code: 2001。 - 匹配策略:按"歌手 + 歌名"查,先剥离版本后缀(remix/live/伴奏/DJ版)再匹配;歌手名、歌名做归一化(去标点、转小写)后做包含判断,避免一字之差漏掉。
- 网易兜底:QQ 查不到时退一步用网易云
music.163.com/api/search/get/web,速度慢但覆盖面不同。 - 断点续传:每 20 条把结果写进
album_ckpt.json,脚本重跑会跳过已查过的,适合大库分多次跑。
6.2 完整代码(并发版)
# -*- coding: utf-8 -*-
"""批量查询缺失专辑信息(并发版):QQ 主力,网易兜底,断点续传"""
import os, re, json, time, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from mutagen.id3 import ID3
ROOT = 'W:/'
CKPT = 'C:/tmp/album_ckpt.json'
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
QQ_H = dict(UA, Referer='https://y.qq.com/', **{'Content-Type': 'application/json'})
NE_H = dict(UA, Referer='https://music.163.com')
lock = threading.Lock()
ne_lock = threading.Lock()
def norm(s):
return re.sub(r'[^\w\u4e00-\u9fff]', '', s.lower())
def strip_ver(t):
return re.sub(r'[\((\[【][^\))\]】]*[\))\]】]', '', t).strip()
sess = requests.Session()
sess.trust_env = False
def qq_search(q):
body = json.dumps({"req_1": {"method": "DoSearchForQQMusicDesktop",
"module": "music.search.SearchCgiService",
"param": {"search_type": 0, "query": q, "page_num": 1, "num_per_page": 6}}}).encode()
for _ in range(2):
try:
d = json.loads(sess.post('https://u.y.qq.com/cgi-bin/musicu.fcg',
data=body, headers=QQ_H, timeout=12).text)
lst = d.get('req_1', {}).get('data', {}).get('body', {}).get('song', {}).get('list', [])
if lst: return lst
except Exception:
pass
time.sleep(1.5)
return []
def ne_search(q):
with ne_lock:
try:
d = sess.get('https://music.163.com/api/search/get/web',
params={'s': q, 'type': 1, 'limit': 5}, headers=NE_H, timeout=12).json()
r = d.get('result', {}).get('songs', [])
except Exception:
r = []
time.sleep(1.0)
return r
def pick_qq(lst, artist, title):
na, nt = norm(artist), norm(title)
best = None
for s in lst:
singers = norm('/'.join(a.get('name', '') for a in s.get('singer', [])))
nm = norm(s.get('name', ''))
if na and na not in singers and singers not in na: continue
if nt and nt != nm and nt not in nm and nm not in nt: continue
if s.get('album', {}).get('name'): return s
best = best or s
return best
def pick_ne(lst, artist, title):
na, nt = norm(artist), norm(title)
for s in lst:
singers = norm('/'.join(a['name'] for a in s.get('artists', [])))
nm = norm(s.get('name', ''))
if na and na not in singers and singers not in na: continue
if nt and nt != nm and nt not in nm and nm not in nt: continue
if s.get('album', {}).get('name'): return s
return None
def work(item):
f, ar, ti = item
rec = {'artist': ar, 'title': ti, 'album': '', 'src': '', 'cover': '', 'hit': ''}
try:
for q in (f'{ar} {ti}', f'{ar} {strip_ver(ti)}'):
lst = qq_search(q)
s = pick_qq(lst, ar, ti) if lst else None
if s and s.get('album', {}).get('name'):
al = s['album']
rec.update(album=al['name'].strip(), src='qq',
cover=al.get('mid') or '', hit=s.get('name', ''))
return f, rec
time.sleep(0.4)
for q in (f'{ar} {ti}', f'{ar} {strip_ver(ti)}'):
lst = ne_search(q)
s = pick_ne(lst, ar, ti) if lst else None
if s:
rec.update(album=s['album']['name'].strip(), src='ne',
cover=str(s['id']), hit=s.get('name', ''))
return f, rec
except Exception:
pass
return f, rec
ck = {}
if os.path.exists(CKPT):
ck = json.load(open(CKPT, encoding='utf-8'))
files = [f for f in sorted(os.listdir(ROOT)) if f.lower().endswith('.mp3')]
todo = []
for f in files:
if f in ck: continue
try:
t = ID3(os.path.join(ROOT, f))
except Exception:
continue
if 'TALB' in t and str(t['TALB']).strip(): continue
ar = str(t['TPE1']) if 'TPE1' in t else ''
ti = str(t['TIT2']) if 'TIT2' in t else ''
if ar and ti: todo.append((f, ar, ti))
print('已有 %d 条,待查 %d 首' % (len(ck), len(todo)), flush=True)
n = 0
with ThreadPoolExecutor(max_workers=5) as ex:
futs = [ex.submit(work, it) for it in todo]
for fu in as_completed(futs):
f, rec = fu.result()
ck[f] = rec
n += 1
if n % 20 == 0:
with lock:
json.dump(ck, open(CKPT, 'w', encoding='utf-8'), ensure_ascii=False)
print('[%d/%d] 命中 %d' % (n, len(todo), sum(1 for v in ck.values() if v['album'])), flush=True)
json.dump(ck, open(CKPT, 'w', encoding='utf-8'), ensure_ascii=False)
print('DONE 总 %d,命中 %d' % (len(ck), sum(1 for v in ck.values() if v['album'])), flush=True)
拿到 album_ckpt.json 后,遍历写回每首歌的 TALB 即可(命中约 632→940,仅少量网络翻唱/现场版留空)。
专辑命名取舍:有些官方专辑名很长(如"李晓杰 把酒倒满"其实是歌手+专辑的打包命名)。如果库里同一歌手多首歌都挂在同一个官方大合辑下,可以适当简化成真正的专辑名,保持一致性即可。不要为了填满而填错——填错专辑比留空更糟。
七、封面校准与错配修复 cover_batch.py
专辑名补上了,但封面可能张冠李戴,或者挂了某 App 的水印图。这一步用感知哈希做"该不该换"的判断,而不是无脑覆盖。
7.1 核心思路
- 用专辑补全阶段拿到的
cover(QQ 的专辑mid或网易的song id)下载官方封面。 - 计算现有封面和官方封面的 dhash(差异哈希),求汉明距离。
- 距离 ≤ 8:判定为"同一张图",保留原图;距离 > 8:差异明显,用官方图替换。
- 没有封面则直接补;下载失败跳过并记录。
QQ 封面地址模板:https://y.gtimg.cn/music/photo_new/T002R500x500M000{mid}.jpg(同样要带 Referer)。
7.2 完整代码
# -*- coding: utf-8 -*-
"""按官方专辑信息批量校准封面:下载 -> 感知哈希比对 -> 差异大则替换"""
import os, io, json, time, csv, re
import requests
from mutagen.id3 import ID3, APIC
from PIL import Image
ROOT = 'W:/'
CKPT = 'C:/tmp/album_ckpt.json'
LOG = 'C:/tmp/cover_batch_ckpt.json'
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
QQ_H = dict(UA, Referer='https://y.qq.com/')
NE_H = dict(UA, Referer='https://music.163.com')
def dhash(im):
im = im.convert('L').resize((9, 8), Image.LANCZOS)
px = list(im.getdata())
bits = []
for r in range(8):
for c in range(8):
bits.append(px[r*9+c] < px[r*9+c+1])
return bits
def ham(a, b):
return sum(1 for x, y in zip(a, b) if x != y)
def get_qq(mid):
for size in ('T002R500x500M000', 'T002R800x800M000'):
try:
b = requests.get('https://y.gtimg.cn/music/photo_new/%s%s.jpg' % (size, mid),
headers=QQ_H, timeout=20).content
im = Image.open(io.BytesIO(b))
if min(im.size) >= 400:
return im, b
except Exception:
pass
return None, None
def get_ne(song_id):
try:
d = requests.get('https://music.163.com/api/song/detail/?id=%s&ids=[%s]' % (song_id, song_id),
headers=NE_H, timeout=20).json()
pic = d['songs'][0]['album'].get('picUrl')
if not pic: return None, None
b = requests.get(pic + '?param=700y700', headers=NE_H, timeout=25).content
return Image.open(io.BytesIO(b)), b
except Exception:
return None, None
ck = json.load(open(CKPT, encoding='utf-8'))
done = {}
if os.path.exists(LOG):
done = json.load(open(LOG, encoding='utf-8'))
rows = []
todo = [(f, r) for f, r in ck.items() if (r.get('album') or '').strip() and f not in done]
print('待校封面 %d 首' % len(todo), flush=True)
for i, (f, rec) in enumerate(todo, 1):
p = os.path.join(ROOT, f)
if not os.path.exists(p):
continue
try:
t = ID3(p)
except Exception:
continue
old = t.getall('APIC')
old_im = None
if old:
try: old_im = Image.open(io.BytesIO(old[0].data))
except Exception: old_im = None
im = b = None
src = rec.get('src')
if src == 'qq' and rec.get('cover'):
im, b = get_qq(rec['cover'])
if im is None and rec.get('cover'):
im, b = get_ne(rec['cover'])
time.sleep(0.7)
if im is None:
done[f] = 'no_download'
rows.append([f, rec.get('album'), '下载失败', '', ''])
else:
acted = 'kept'
if old_im is None:
acted = 'added'
else:
dh = ham(dhash(old_im), dhash(im))
if dh > 8:
acted = 'replaced'
else:
acted = 'same'
if acted in ('added', 'replaced'):
buf = io.BytesIO()
im.convert('RGB').save(buf, 'JPEG', quality=92)
nj = buf.getvalue()
for k in list(t.keys()):
if k.startswith('APIC'): del t[k]
t.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=nj))
t.save(p, v2_version=3)
done[f] = acted
rows.append([f, rec.get('album'), acted,
'%dx%d' % im.size, dh if old_im else ''])
if i % 10 == 0 or i == len(todo):
json.dump(done, open(LOG, 'w', encoding='utf-8'), ensure_ascii=False)
print('[%d/%d]' % (i, len(todo)), flush=True)
json.dump(done, open(LOG, 'w', encoding='utf-8'), ensure_ascii=False)
with open('W:/_封面校准日志.csv', 'w', newline='', encoding='utf-8-sig') as fp:
w = csv.writer(fp)
w.writerow(['文件', '专辑', '动作', '新图尺寸', '汉明距离'])
w.writerows(rows)
from collections import Counter
print(Counter(done.values()))
实测 552 首待校:517 首判定 same/kept,35 首替换。替换下来的多是跨专辑错配(比如同一张"网络歌手合辑图"被复用到很多首不同歌上)和带厂牌 logo 的非官方图。
汉明距离阈值怎么定:8 是个经验值。低于它两张图肉眼基本一样(只是压缩差异);高于它多半是不同图。可以先小批量试跑,看
W:/_封面校准日志.csv里的距离分布再调。
八、修复脚本 fix_final.py:标签精简、乱码、改名
验收报告出来后,针对具体问题批量修。几个典型场景:
8.1 删 ID3v1 + 清冗余帧
def strip_v1(p):
with open(p, 'rb') as f:
f.seek(0, 2); size = f.tell()
if size < 128: return False
f.seek(-128, 2)
if f.read(3) != b'TAG': return False
with open(p, 'r+b') as f:
f.truncate(size - 128)
return True
def clean_frames(p, KEEP={'TIT2','TPE1','TALB','APIC'}):
t = ID3(p); removed = []
for k in list(t.keys()):
if k.split(':')[0] not in KEEP:
removed.append(k); del t[k]
if removed: t.save(p, v2_version=3)
return removed
8.2 专辑乱码反解
有些歌的专辑名是 ÃÂ¥... 这种编码错位。尝试用 latin-1 / cp1252 / cp437 编码回去再用 gbk 解码,能解出汉字就修复:
old = str(t['TALB'])
fixed = None
for enc in ('latin-1', 'cp1252', 'cp437'):
try:
cand = old.encode(enc, errors='strict').decode('gbk')
if re.search(r'[\u4e00-\u9fff]', cand):
fixed = cand; break
except Exception:
continue
if fixed:
t['TALB'] = TALB(encoding=3, text=fixed)
t.save(p, v2_version=3)
8.3 命名与标签方向修正
例如某首 网虫 的 Remix,文件名带不可见 NBSP、标签歌手/歌名写反了,直接用脚本重写 TPE1/TIT2 并重命名。
注意误报:像
DAISHI DANCE & Cécile Corbel里的ç、é是法语正常字符,不是乱码,脚本的MOJI_HINT规则会放过它们——验收时别手滑当成乱码修了。
九、单文件 FLAC 入库示例 import_flac.py
偶尔手头就一个 FLAC 要加进库,不必跑全库转码,单独处理即可:
# 转码
ff = imageio_ffmpeg.get_ffmpeg_exe()
cmd = [ff, '-y', '-i', SRC, '-codec:a', 'libmp3lame', '-b:a', '320k',
'-ac', '2', '-ar', '44100', '-map_metadata', '-1', '-id3v2_version', '3', TMP]
subprocess.run(cmd, capture_output=True)
# 下载官方封面(QQ)
UA = {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://y.qq.com/'}
b = requests.get('https://y.gtimg.cn/music/photo_new/T002R500x500M000%s.jpg' % MID,
headers=UA, timeout=20).content
# 写标准四帧
t = ID3()
t.add(TIT2(encoding=3, text=TITLE))
t.add(TPE1(encoding=3, text=ARTIST))
t.add(TALB(encoding=3, text=ALBUM))
t.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=cover))
t.save(TMP, v2_version=3)
# 规范命名入库,原 FLAC 移备份
os.replace(TMP, FINAL)
shutil.move(SRC, os.path.join(bak, os.path.basename(SRC)))
封面选择:DJ 版、Remix 版常有官方封面但带厂牌 logo,视觉上不够干净。这时可以退一步用原唱版本的同名专辑图(如用蒋雪儿原版专辑图替代 DJ 版带 logo 的封面),更统一。
十、整理注意事项与踩坑(重点)
这几条都是真金白银踩出来的坑,建议先读再动手:
QQ 接口必须带
Referer: https://y.qq.com/。不带会返回code: 2001,以为接口挂了,其实是反爬。网易同理要带Referer: https://music.163.com。限流与重试。连续高频请求会被限流返回空结果。做法:每次请求后
time.sleep(0.4~0.8);失败重试 1~2 次;大库用断点续传分次跑。别用太快并发,5 线程足够。封面别把广告当官方图。很多下载源的音乐,封面其实是"某音乐 App 推荐""歌手合辑通用图"或厂牌 logo。处理原则:
- 优先用 QQ/网易官方专辑图;
- 对"一堆歌共用同一张图"保持警惕,多半是错配;
- 用感知哈希比对再决定是否替换,不要无脑覆盖;
- 个别官方封面本身就是设计的一部分(如《寻找周杰伦》EP 封面上的注音键位彩蛋
vmp6513 5.ru,6xjp6,经查证是官方设计,不是广告),别误删。
乱码 vs 正常外语字符。乱码识别规则要放过法语重音字母(ç é è 等),否则会"修"出乱子。判断编码错位的核心技巧是
cp437 → gbk反解能解出连续汉字。OST 残留、下划线、不可见字符。文件名里
Ost/OST标记、_分隔、NBSP 空格都是不规范,验收脚本会标出来,统一改成歌手 - 歌名(空格+短横)。有损转换要保守。FLAC→MP3 是不可逆的有损操作,原 FLAC 移备份目录而不是直接删。128k 低码率源也别硬升——升码率不会凭空变清晰,只能是"合格但糊",这种最好换源,而不是骗验收。
命名即真相。把"歌手 - 歌名"作为唯一命名规范,标签与文件名互相校验。文件名错了,验收脚本能帮你发现标签和文件名不一致。
重复要人工确认,别自动删。"原曲版 + DJ 版"、同名不同录音(如《春庭雪》两个文件时长 240s vs 423s 是不同录音)看起来像重复,其实是不同版本。验收脚本只列出重复组,删除留给人工判断。
十一、推荐工作流
1. 把所有音乐(含 FLAC)丢进 ROOT 目录,平铺。
2. 跑 album_query:补全专辑名(QQ 主力 + 网易兜底 + 断点续传)。
3. 跑 cover_batch:按官方图校准封面(感知哈希比对)。
4. 跑 final_audit:自动转码 FLAC + 全库逐项验收,生成 CSV 报告。
5. 看报告,按"问题项"列分批跑 fix_final:删 ID3v1、清冗余帧、修乱码、改命名。
6. 复查 final_audit,直到"完全合格"接近 100%。
7. 重复组人工确认,再决定是否删。
每一步都输出日志 CSV(专辑补全日志、封面校准日志、验收报告),随时可追、可重跑、可回退。
十二、成果与遗留
以本文实战的曲库为例:
- 入库后 959 首 MP3,930 首完全合格(97%)。
- 遗留约 29 首,主要是:少量 128k 低码率源(需会员源替换)、个别音源固有低采样(如 32000Hz)、以及 4 组需人工确认的"原曲/DJ 版"疑似重复。
- 这些都不是脚本能自动解决的硬伤,需要换更好的音源或人工判断,所以让它们"留白"比"硬凑合格"更诚实。
小结
整理音乐库的本质,不是写几个脚本,而是先把"合格"定义清楚,再用自动化把定义变成可检查的事实。验收脚本是这条流水线的"质量门禁"——它不负责修,只负责如实告诉你哪里不合格,剩下的修复交给针对性脚本。
上面五个脚本(final_audit / album_query / cover_batch / fix_final / import_flac)组合起来,就能把你那个乱糟糟的文件夹,变成一个命名统一、标签干净、封面正规、可随时复查的标准曲库。