在树莓派、NAS 或迷你主机上用 Docker 部署 Navidrome,再写一个小命令行工具,直接通过 Subsonic API 和服务器交互:检查连接、列出歌手和专辑、搜索、下载或转码一首歌。手机上的 Subsonic 客户端用的也是这套接口,所以弄懂它,就知道客户端连不上时该从哪里查起。
先看清成品
- 实测环境: navidrome 0.64.2,API 1.16.1,openSubsonic=True。
- 已测命令: ping、歌手、专辑、搜索、stream(得到 flac 文件)都可用;密码错误时返回错误码 40。
- 测试: pytest 1 项通过,覆盖 token 的计算。
- 调试示例: 把 salt 和密码的拼接顺序弄反就会认证失败(见调试示例)。
注意:只给自己有权使用的音乐建库,比如自己购买的 CD 转录、正版下载、开放授权的作品。不要用 Navidrome 给别人分发音乐。本篇的测试音频全部是 ffmpeg 生成的正弦波。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
docker-compose.yml |
官方示例的写法:user、/data、只读的 /music、定时扫描 |
subsonic_cli.py |
实现 token 认证:每次请求随机 salt,网络上不传输明文密码 |
test_auth.py |
验证 t = md5(密码 + salt) |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| 硬件 | 树莓派 4/5、NAS 或迷你主机 | Navidrome 不需要 GPU |
| Docker | 带 Compose 插件 | |
| Python | 3.8 以上 | pip install requests pytest |
文件结构
40-navidrome-A-src/
├── docker-compose.yml
├── subsonic_cli.py
└── test_auth.py
第 1 步:部署
docker-compose.yml:
# Navidrome(官方文档的示例写法),树莓派 4/5、NAS、迷你主机都能跑
services:
navidrome:
image: navidrome/navidrome:latest
user: "1000:1000" # 这个用户要拥有 data 目录,并且能读取音乐目录
ports:
- "4533:4533"
environment:
ND_SCANNER_SCHEDULE: "@every 1h" # 定时扫描(cron 语法)
ND_LOGLEVEL: info
volumes:
- ./data:/data
- /srv/music:/music:ro
restart: unless-stopped
docker compose up -d 启动后,打开 http://主机:4533 创建第一个管理员账号。
第 2 步:命令行工具
subsonic_cli.py:
"""用 Subsonic API 操作 Navidrome(大多数手机音乐客户端用的也是这套接口)。
认证方式(Subsonic API 1.13.0 起):每次请求生成随机 salt,t = md5(密码 + salt),同时传 u / t / s / v / c;密码本身不在网络上传输。
环境变量:ND_URL(默认 http://127.0.0.1:4533)、ND_USER、ND_PASS
用法:python subsonic_cli.py ping | artists | albums | search 关键词 | stream 歌曲ID 输出文件 [最大码率kbps 格式]"""
import hashlib, os, secrets, sys
import requests
URL = os.environ.get("ND_URL", "http://127.0.0.1:4533").rstrip("/")
def auth():
salt = secrets.token_hex(6)
return {"u": os.environ.get("ND_USER", "admin"), "t": hashlib.md5((os.environ["ND_PASS"] + salt).encode()).hexdigest(),
"s": salt, "v": "1.16.1", "c": "subsonic_cli", "f": "json"}
def call(endpoint, **params):
r = requests.get(f"{URL}/rest/{endpoint}", params={**auth(), **params}, timeout=30); r.raise_for_status()
body = r.json()["subsonic-response"]
if body["status"] != "ok": raise SystemExit(f"{endpoint} 失败:{body['error']['code']} {body['error']['message']}")
return body
def main(cmd, *a):
if cmd == "ping":
b = call("ping"); print(f"✓ {b.get('type', '')} {b.get('serverVersion', '')} API {b['version']} openSubsonic={b.get('openSubsonic', False)}")
elif cmd == "artists":
for idx in call("getArtists")["artists"].get("index", []):
for ar in idx["artist"]: print(f"{ar['name']} ({ar.get('albumCount', 0)} 张专辑) id={ar['id']}")
elif cmd == "albums":
for al in call("getAlbumList2", type="alphabeticalByName", size=50)["albumList2"].get("album", []):
print(f"{al.get('artist', '?')} - {al['name']} ({al.get('year', '')}) {al.get('songCount')} 首 id={al['id']}")
elif cmd == "search":
for s in call("search3", query=a[0], songCount=20)["searchResult3"].get("song", []):
print(f"{s.get('artist', '?')} - {s['title']} [{s.get('suffix')} {s.get('bitRate')}kbps] id={s['id']}")
elif cmd == "stream":
extra = {"maxBitRate": a[2], "format": a[3]} if len(a) >= 4 else {}
r = requests.get(f"{URL}/rest/stream", params={**auth(), "id": a[0], **extra}, timeout=60); r.raise_for_status()
if "json" in r.headers.get("Content-Type", ""): raise SystemExit(r.text[:200])
open(a[1], "wb").write(r.content); print(f"已保存 {a[1]}({len(r.content)} 字节,{r.headers.get('Content-Type')})")
else: raise SystemExit(__doc__)
if __name__ == "__main__":
main(*sys.argv[1:]) if len(sys.argv) > 1 else print(__doc__)
test_auth.py:
import hashlib
import subsonic_cli
def test_token(monkeypatch):
monkeypatch.setenv("ND_PASS", "sesame")
a = subsonic_cli.auth()
assert a["t"] == hashlib.md5(("sesame" + a["s"]).encode()).hexdigest() # 顺序:密码在前,salt 在后
assert "p" not in a and a["v"] == "1.16.1" and a["f"] == "json"
assert subsonic_cli.auth()["s"] != a["s"] # 每次请求都换一个新的 salt
$ export ND_URL=http://nas:4533 ND_USER=admin ND_PASS='你的密码'
$ python subsonic_cli.py ping
✓ navidrome 0.64.2 (10114574) API 1.16.1 openSubsonic=True
$ python subsonic_cli.py albums
[Unknown Artist] - [Unknown Album] () 1 首 id=69IwB2p7tQDejD3lowUIFo
Synth Artist - Demo Album (2026) 4 首 id=5zJ8FDsXnofCCRX0YudGDd
Synth Artist - Second Album (2025) 1 首 id=69lpKlR7yVX9m5m6RhBLDW
$ python subsonic_cli.py search Sine
Synth Artist - Sine 220 Hz [flac 96kbps] id=02b3GLwsyjmb30IBy1bwNY
…
$ ND_PASS=wrong python subsonic_cli.py ping
ping 失败:40 Wrong username or password
检查点:ping 返回的 openSubsonic=True 说明服务器支持 OpenSubsonic 扩展。[Unknown Artist] 是一首缺少标签的歌,B 篇专门处理这个问题。
调试示例:认证总是返回 40
第一版写成了 md5(salt + 密码),每个请求都返回:
ping 失败:40 Wrong username or password
而密码明明是对的。Subsonic API 文档规定的计算方式是 token = md5(password + salt),密码在前、salt 在后。改正以后 ping 成功。test_auth.py 用固定的 salt 验证拼接顺序,避免以后有人重构时又改回去。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 错误码 40 | 密码错误,或 token 的拼接顺序弄反了 | 检查 md5(密码+salt) |
| 专辑是空的 | 还没扫描完,或者 user 对音乐目录没有读权限 |
看 Navidrome 日志,修改目录权限 |
| 新加的歌没出现 | 还没到定时扫描的时间 | 调整 ND_SCANNER_SCHEDULE,或者用 B 篇的 --rescan |
模板规则
- 用 token 认证,不在 URL 里放明文密码(不用
p=参数)。 - 音乐目录只读挂载。
- 给自动化脚本单独建一个非管理员用户。
项目资源
| 用途 | 地址 |
|---|---|
| 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 Docker 安装 | https://www.navidrome.org/docs/installation/docker/ |
Subsonic API 很简单:每个请求都带 u、t、s、v、c 这几个参数。弄清楚它,大部分客户端连不上的问题都能自己排查。