Navidrome 按文件里的标签来组织音乐,不看文件夹名。所以缺标签的歌会全部落进
[Unknown Artist]。这个脚本在导入前检查标签;对于放在歌手/专辑 (年份)/NN - 标题结构里的文件,按路径补上标签(默认只打印计划,--apply才写入),然后请 Navidrome 重新扫描,等扫描完成后报告结果。
先看清成品
- 测试: pytest 2 项通过。
- 实测: 清空
04 - Sine 550 Hz.mp3的标签后运行脚本,正确推断出 artist、album、title、track=4、date=2026 并写入。在 Navidrome 里重新扫描后,Demo Album 从 3 首变成 4 首。- 直接放在根目录的
untagged_track.mp3推断不出来,脚本列出来留给人工处理。
注意:只给自己有权使用的音乐建库,比如自己购买的 CD 转录、正版下载、开放授权的作品。不要用 Navidrome 给别人分发音乐。本篇的测试音频全部是 ffmpeg 生成的正弦波。
写标签会修改文件本身。第一次使用前先备份音乐库,或者先在一份拷贝上试。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
fix_tags.py |
用 mutagen 检查 artist、album、title、tracknumber;按路径推断,支持 1-03 这种“碟号-曲目号”;--rescan 调用 startScan/getScanStatus |
subsonic_cli.py |
和 A 篇相同,供 --rescan 调用 |
test_tags.py |
测试路径推断和碟号的解析 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.8 以上 | pip install mutagen requests pytest |
| 账号 | Navidrome 管理员账号 | 触发扫描需要管理员权限 |
| 复杂的音乐库 | MusicBrainz Picard 或 beets | 按指纹或数据库匹配,比按路径推断更准确 |
文件结构
40-navidrome-B-src/
├── fix_tags.py
├── subsonic_cli.py
└── test_tags.py
第 1 步:脚本
fix_tags.py:
"""导入 Navidrome 之前先检查音乐标签:Navidrome 按文件里的标签(不是文件夹名)组织歌手、专辑,缺标签的歌会落进 [Unknown Artist]。
缺标签时,按目录结构 歌手/专辑 (年份)/NN - 标题.ext 推断出标签;默认只打印,加 --apply 才会写入(建议先备份)。
推断不出来的文件(例如直接放在根目录的)会列出来,留给你手动处理,或者交给 beets、MusicBrainz Picard 这类工具。
用法:python fix_tags.py 音乐目录 [--apply] [--rescan] --rescan 需要 ND_URL / ND_USER / ND_PASS"""
import re, sys, time
from pathlib import Path
import mutagen
EXT = {".mp3", ".flac", ".m4a", ".ogg", ".opus"}
ALBUM = re.compile(r"^(?P<album>.+?)(?: \((?P<year>\d{4})\))?$")
TRACK = re.compile(r"^(?:(?P<disc>\d)-(?=\d{2}))?(?P<no>\d{1,3})\s*[-.]?\s+(?P<title>.+)$") # 支持 “1-03 标题” 这种 碟号-曲目号
NEED = ("artist", "album", "title", "tracknumber")
def current(f):
m = mutagen.File(f, easy=True)
return {k: (m.get(k) or [""])[0] for k in NEED + ("date", "albumartist")} if m else None
def infer(root, f):
rel = f.relative_to(root).parts
if len(rel) != 3: return None
al, tr = ALBUM.match(rel[1]), TRACK.match(f.stem)
if not tr: return None
out = {"artist": rel[0], "albumartist": rel[0], "album": al["album"], "title": tr["title"], "tracknumber": str(int(tr["no"]))}
if al["year"]: out["date"] = al["year"]
if tr["disc"]: out["discnumber"] = tr["disc"]
return out
def scan(root):
root = Path(root)
for f in sorted(p for p in root.rglob("*") if p.suffix.lower() in EXT):
tags = current(f)
missing = [k for k in NEED if not tags or not tags[k]]
if missing: yield f, missing, infer(root, f)
def write(f, new):
m = mutagen.File(f, easy=True)
for k, v in new.items():
if not (m.get(k) or [""])[0]: m[k] = v # 只补缺的,不覆盖已有的
m.save()
if __name__ == "__main__":
root, todo = sys.argv[1], 0
for f, missing, guess in scan(root):
todo += 1; print(f"✗ {Path(f).relative_to(root)} 缺少 {missing}")
if not guess: print(" → 推断不出来,需要手动处理"); continue
print(" → " + ",".join(f"{k}={v}" for k, v in guess.items()))
if "--apply" in sys.argv: write(f, guess); print(" 已写入")
print(f"共 {todo} 个文件缺少标签" if todo else "✓ 标签齐全")
if "--rescan" in sys.argv:
import subsonic_cli as s
s.call("startScan", fullScan="true")
while s.call("getScanStatus")["scanStatus"]["scanning"]: time.sleep(1)
print("✓ Navidrome 扫描完成")
test_tags.py:
import shutil, subprocess
from pathlib import Path
from fix_tags import scan, write, current
def test_infer_and_write(tmp_path):
d = tmp_path / "Artist X" / "Best Of (2019)"; d.mkdir(parents=True)
f = d / "07 - Hello World.mp3"
subprocess.run(["ffmpeg", "-loglevel", "error", "-f", "lavfi", "-i", "sine=d=1", "-c:a", "libmp3lame", str(f)], check=True)
shutil.copy(f, tmp_path / "loose.mp3")
got = {p.name: g for p, _, g in scan(tmp_path)}
assert got["07 - Hello World.mp3"] == {"artist": "Artist X", "albumartist": "Artist X", "album": "Best Of", "title": "Hello World", "tracknumber": "7", "date": "2019"}
assert got["loose.mp3"] is None
write(f, got["07 - Hello World.mp3"])
assert current(f)["title"] == "Hello World" and [p.name for p, _, _ in scan(tmp_path)] == ["loose.mp3"]
def test_disc_track():
from fix_tags import TRACK
m = TRACK.match("1-03 Intro"); assert (m["disc"], m["no"], m["title"]) == ("1", "03", "Intro")
m = TRACK.match("2-11 - Outro"); assert (m["disc"], m["no"], m["title"]) == ("2", "11", "Outro")
m = TRACK.match("04 - Sine 550 Hz"); assert (m["disc"], m["no"], m["title"]) == (None, "04", "Sine 550 Hz")
m = TRACK.match("07. Hello"); assert (m["no"], m["title"]) == ("07", "Hello")
第 2 步:先看计划,再写入
$ python fix_tags.py /srv/music
✗ Synth Artist/Demo Album (2026)/04 - Sine 550 Hz.mp3 缺少 ['artist', 'album', 'title', 'tracknumber']
→ artist=Synth Artist,albumartist=Synth Artist,album=Demo Album,title=Sine 550 Hz,tracknumber=4,date=2026
✗ untagged_track.mp3 缺少 ['artist', 'album', 'title', 'tracknumber']
→ 推断不出来,需要手动处理
共 2 个文件缺少标签
$ python fix_tags.py /srv/music --apply --rescan
…
已写入
…
检查点:python subsonic_cli.py albums 显示 Demo Album (2026) 4 首。
调试示例:“1-03 Intro” 被解析成了第 1 首
多碟专辑常用 1-03 Intro.flac 这种命名。第一版的正则把它解析成了 track=1,title=“03 Intro”。我给正则加了一个可选的碟号分组 (?:(?P<disc>\d)-(?=\d{2}))?,现在解析结果是 disc=1、track=3、title=Intro,并在 test_tags.py 里加了这个用例。
另外,第一版在写入前多调用了一次 add_tags()。测试表明,没有标签的 MP3 用 EasyMP3 也能直接写入,所以把这一行删掉了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 改了标签,Navidrome 里没变 | 还没重新扫描 | 加 --rescan,或者等定时扫描 |
| 同一张专辑被拆成好几张 | albumartist 不一致 | 统一 albumartist |
| 推断不出来 | 文件不在规定的目录结构里 | 用 Picard 或 beets 处理 |
模板规则
- 先打印计划,再写入;写入前先备份。
- 只补缺失的标签,不覆盖已有的。
- 推断不出来的文件列出来交给人工处理,不要乱猜。
项目资源
| 用途 | 地址 |
|---|---|
| Navidrome 官方文档 | https://www.navidrome.org/docs/ |
| Subsonic API | https://www.subsonic.org/pages/api.jsp |
| Navidrome 的 Subsonic 兼容说明 | https://www.navidrome.org/docs/developers/subsonic-api/ |
| Navidrome 标签说明 | https://www.navidrome.org/docs/usage/tagging-guidelines/ |
| mutagen 文档 | https://mutagen.readthedocs.io/ |
| MusicBrainz Picard | https://picard.musicbrainz.org/ |
| beets | https://beets.io/ |
标签准了,Navidrome 和所有客户端的显示才会准。这个脚本只处理最常见的情况,其余的交给专业工具。