效率工具

Navidrome 按文件里的标签来组织音乐,不看文件夹名

Navidrome 按文件里的标签来组织音乐,不看文件夹名。所以缺标签的歌会全部落进 [Unknown Artist]。这个脚本在导入前检查标签;对于放在 歌手/专辑 (年份)/NN - 标题 结构里的文件,按路径补上标签(默认只打印计划,--apply 才写入),然后请 Navidrome 重新扫描,等扫描完成后报告结果。

BINBHO · 图文教程

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 和所有客户端的显示才会准。这个脚本只处理最常见的情况,其余的交给专业工具。