云端部署

Navidrome 部署与 Subsonic API 命令行工具实战

在树莓派、NAS 或迷你主机上用 Docker 部署 Navidrome,再写一个小命令行工具,直接通过 Subsonic API 和服务器交互:检查连接、列出歌手和专辑、搜索、下载或转码一首歌。手机上的 Subsonic 客户端用的也是这套接口,所以弄懂它,就知道客户端连不上时该从哪里查起。

BINBHO · 图文教程

在树莓派、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 这几个参数。弄清楚它,大部分客户端连不上的问题都能自己排查。