
整理古典音乐音频库时我遇到的第一个坑往往不是存储空间而是标题结构。比如蔡珂宜 - Allegro-Capriccio from Locatellis Harmonic Labyrinth这一条音轨名演奏者、曲名和来源作品都写在同一个字符串里。如果平台直接把这条字符串写到播放器文本或文件标题里后面想做作曲者筛选、作品版本归并、演奏者统计都会很痛苦。这篇内容把这串文字当作一个典型的元数据清洗场景介绍如何把它解析成结构化字段再写回音频标签并用ffprobe做结果验证。1. 一条音轨标题为什么会成为数据问题1.1 人眼能看懂数据库看不懂单看字符串本身演奏者是蔡珂宜乐曲片段是Allegro-Capriccio来源作品是洛卡泰利的《和谐迷宫》大部分人不会读错。问题在于数据库无法自动完成这个判断。如果把这条字符串直接塞进文件的title字段会造成几个实际后果想按作曲者筛选所有洛卡泰利作品时需要先解析Locatellis这个写法。想统计同一个作品被不同演奏者录制过多少版本时无法用作品名做关联。想正确显示播放列表时许多播放器会把整串文字当作歌曲名而不是把演奏者显示在艺术家栏。想导出的报告中只保留章节名时没有字段能单独取出来。所以这并不是排版问题而是数据模型问题。音乐平台上的“播放单条曲目”看起来很简单背后如果没有结构化字段搜索、标签、推荐都做不稳。1.2 先分清作品、演奏者与音轨标题做古典音乐元数据整理第一件事不是写正则而是把概念拆开。层级本条标题里的内容建议字段作用演奏者蔡珂宜performer或artist记录谁在演奏乐章/片段Allegro-Capricciomovement或title记录这一轨具体是什么来源作品Locatellis Harmonic Labyrinthwork_source/work_key把多个乐章归并到同一部作品作曲者Locatellicomposer用于按作者检索和统计一个文件在播放器里通常只有title和artist两个可见字段但这不代表你的数据层也要只有两个字段。标题可以展示成一条拼接字符串后台则必须保留独立字段。1.3 解析规则只能切分不能代替权威数据标题字符串适合用来做“候选信息”不适合当作唯一事实来源。例如Locatellis Harmonic Labyrinth这个写法可以解析出作曲者姓Locatelli但要想把作品标准化成“Pietro Locatelli 的某部编号作品”还需要查权威资料或人工维护映射表。在自动化脚本里稳妥的做法是能解析出来的字段先解析。无法确认的信息留空。留下原始标题避免丢失证据。无法标准化的记录进入人工 review 列表。2. 用最小 Python 项目做标题规范化2.1 环境准备先创建独立虚拟环境避免污染系统 Python。mkdir music-catalog cd music-catalog python3 -m venv .venv source .venv/bin/activate需要安装的 Python 库主要是mutagen它负责读取和写入音频文件标签。pip install mutagen如果没有现成测试音频可以用ffmpeg生成一个短 FLAC 文件。mkdir -p sample ffmpeg -f lavfi -i sinefrequency440:duration2 -c:a flac sample/track.flac这里生成的文件内容不用于音乐播放只用来测试标签写入是否生效。2.2 项目结构与数据模型项目结构可以这样组织music-catalog/ ├── sample/ │ └── track.flac ├── models.py ├── normalizer.py ├── tagging.py ├── main.py └── requirements.txt先定义结构化数据模型。TrackMetadata用来保存解析结果包含原始标题和各个拆解字段。from __future__ import annotations from dataclasses import dataclass, asdict dataclass class TrackMetadata: source_text: str performer: str | None None work_source: str | None None work_key: str | None None composer: str | None None movement: str | None None def to_dict(self) - dict: return asdict(self)这里没有把合成后的完整标题放进来因为原始标题已经保存在source_text中。后面对账、排查、去重时这个字段是最后的数据保险。2.3 解析逻辑先清理字符再切分 segments很多音轨标题从不同渠道下载后混入了全角空格、不间断空格、连续空格和类型不同的引号。写任何解析规则之前先统一字符。import re import unicodedata from models import TrackMetadata COMPOSER_ALIASES { locatelli: Pietro Locatelli, # 按自己的曲库继续补充例如 # bach: Johann Sebastian Bach, } def _compact_text(raw: str) - str: text unicodedata.normalize(NFKC, raw) text text.replace(\t, ).replace(\u00a0, ) text re.sub(r\s, , text).strip() return text def _resolve_composer(work_source: str | None): 尝试从 Locatellis Harmonic Labyrinth 中拆出 Locatelli。 if not work_source: return None, None matched re.search(r\b([A-Za-z][A-Za-z .-]*?)s\b, work_source) surname matched.group(1).strip() if matched else None composer None if surname: composer COMPOSER_ALIASES.get(surname.lower(), surname) # work_key 只做归并线索不做权威作品名 work_key re.sub(r[^a-z0-9], -, work_source.lower()).strip(-) return composer, work_key def parse_title(raw: str) - TrackMetadata: raw _compact_text(raw) performer None remainder raw # 这类标题先用“ - ”分离演奏者 if - in raw: performer, remainder [part.strip() for part in raw.split( - , 1)] movement None work_source None # 当前标题是“片段名 from 来源作品”的结构 matched re.search(r\bfrom\b\s(.)$, remainder, flagsre.IGNORECASE) if matched: movement_text remainder[: matched.start()].strip() movement movement_text or None work_source _compact_text(matched.group(1)) else: movement remainder or None composer, work_key _resolve_composer(work_source) return TrackMetadata( source_textraw, performerperformer, work_sourcework_source, work_keywork_key, composercomposer, movementmovement, )这段代码有几个关键决策。第一先做 Unicode NFKC 标准化。全角英文、全角空格、特殊引号会被统一成适合程序处理的形态避免后续正则漏匹配。第二from关键字是这类标题的天然分隔点。Allegro-Capriccio放在from之前来源作品放在from之后因此不需要把整条字符串按通用分隔符粗暴切分。第三work_key只做数据库归并用不做显示。它把Locatellis Harmonic Labyrinth转成全小写的locatellis-harmonic-labyrinth这样即使原始标题首字母大小写不同去重时也能找到关联项。解析一行看效果from normalizer import parse_title raw 蔡珂宜 - Allegro-Capriccio from Locatellis Harmonic Labyrinth meta parse_title(raw) print(meta.to_dict())预期结果类似{ source_text: 蔡珂宜 - Allegro-Capriccio from Locatellis Harmonic Labyrinth, performer: 蔡珂宜, work_source: Locatellis Harmonic Labyrinth, work_key: locatellis-harmonic-labyrinth, composer: Pietro Locatelli, movement: Allegro-Capriccio, }composer能被还原成Pietro Locatelli是因为COMPOSER_ALIASES里有映射。如果没有命中映射代码会保留原有姓氏但后续仍应进入人工核对流程。3. 把结构化结果写回音频文件标签3.1 为什么用标签而不是只改文件名有些人会把整理结果直接拼到文件名里例如Pietro Locatelli - Harmonic Labyrinth - Allegro-Capriccio - 蔡珂宜.flac。这样做确实更易读但数据库和播放器不会把文件名当作可靠字段。文件名可以被用户随意修改也可能因为操作系统限制被截断。把信息写入 FLAC、MP3 的标签字段才能让播放器、媒体服务和后端工具读取到稳定的元数据。3.2 FLAC 标签写入示例FLAC 使用 Vorbis Comment字段名比较直观直接使用title、artist、composer、work。from pathlib import Path from mutagen.flac import FLAC from models import TrackMetadata def apply_flac_tags(path: str, meta: TrackMetadata, dry_run: bool False) - bool: file_path Path(path) if file_path.suffix.lower() ! .flac: return False audio FLAC(str(file_path)) if dry_run: return True if meta.movement: audio[title] meta.movement if meta.performer: audio[artist] meta.performer if meta.composer: audio[composer] meta.composer if meta.work_source: audio[work] meta.work_source audio.save() return True这里注意title写入的是movement而不是整串蔡珂宜 - Allegro-Capriccio ...。播放器展示标题时只需展示这一轨的乐章或片段名演奏者信息放在artist字段中。MP3 的标签字段与 FLAC 不同实际项目需要按格式分别处理。MP3 使用的是 ID3v2title、artist、composer分别对应TIT2、TPE1、TCOM帧。如果没有现成的work标准帧可以写入自定义的TXXX:WORK。from mutagen.id3 import ID3, TIT2, TPE1, TCOM audio ID3(path) audio.add(TIT2(encoding3, textmeta.movement or )) audio.add(TPE1(encoding3, textmeta.performer or )) audio.add(TCOM(encoding3, textmeta.composer or )) audio.save()上面只是示意落地前要确认 MP3 文件是否有旧标签、编码版本是否兼容不能对真实文件直接套用。3.3 批量处理与 dry-run把解析和写标签组装到命令行入口中。import argparse from pathlib import Path from normalizer import parse_title from tagging import apply_flac_tags def main() - None: parser argparse.ArgumentParser() parser.add_argument(directory, help要扫描的目录) parser.add_argument(--dry-run, actionstore_true, help只打印结果不写标签) args parser.parse_args() for file_path in Path(args.directory).rglob(*.flac): meta parse_title(file_path.stem) print(f{file_path.name} {meta.to_dict()}) if not meta.composer and not meta.work_source: print(f[review] 需要人工确认: {file_path}) continue apply_flac_tags(str(file_path), meta, dry_runargs.dry_run) if __name__ __main__: main()先使用--dry-run预览python main.py sample --dry-run确认解析结果符合预期后再真正写入标签python main.py sample这一步背后还有一个原则宁可漏写一个字段也不要把错字段写进文件。漏写字段可以靠 review 流程补齐写错字段会让整批文件的数据都不可信。4. 入库后的去重与作品归并4.1 用 SQLite 保存结构化结果标签写入完成后还要把解析结果汇总到数据库中否则无法做统计和去重。这里用 SQLite 作为示例。CREATE TABLE IF NOT EXISTS tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT UNIQUE NOT NULL, source_text TEXT NOT NULL, performer TEXT, composer TEXT, work_source TEXT, work_key TEXT, movement TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP );work_key是最关键的归并字段。它不一定规范但能把写法相近的标题汇聚到一起。SELECT work_key, composer, COUNT(*) AS version_count FROM tracks WHERE work_key locatellis-harmonic-labyrinth GROUP BY work_key, composer;这种查询可以快速发现同一作品的重复条目并根据performer判断哪些是不同的录音版本。4.2 保留原始标题避免过度清洗清洗数据时最容易犯的错误是只保留“清洗后”的干净字段把原始字段删掉。一旦解析规则写错或者后续发现原始标题包含重要上下文就会失去恢复线索。入库时建议至少保留三层信息字段作用示例file_path定位文件/data/music/track.flacsource_text原始标题作为数据证据蔡珂宜 - Allegro-Capriccio from Locatellis Harmonic Labyrinth结构化字段提供查询能力performer蔡珂宜,composerPietro Locatelli如果做长期维护还应该增加人工审核表把每次无法解析的标题、解析后仍不确定的内容放进去由人工决定是否覆盖。5. 用 ffprobe 验证写入结果5.1 验证命令与预期输出标签写完不能只看脚本是否报错还要从文件本身读取。ffprobe -v error \ -show_entries format_tagstitle,artist,composer,work \ -of json sample/track.flac预期输出类似{ format: { tags: { title: Allegro-Capriccio, artist: 蔡珂宜, composer: Pietro Locatelli, work: Locatellis Harmonic Labyrinth } } }如果没有看到composer或work不要先怀疑mutagen先检查是不是扫描了错误的文件或者原始文件名不是预期格式。5.2 常见异常和排查路径现象可能原因检查方式处理建议mutagen.flac.FLAC: invalid文件扩展名是.flac实际格式不是 FLAC执行file sample/track.flac查看真实类型按真实格式调用对应标签库解析后performer为空原始标题没有使用-分隔打印source_text观察分隔符增加规则识别中英文连字符、全角破折号composer为空from后没有xxxs结构输出work_source字段进入人工 review不要硬填写入多个文件后有些标签不生效只扫描了根目录未递归子目录检查扫描路径参数使用rglob递归并确认软链目录不会重复扫描mut