在 NAS 或迷你主机上用 Docker 部署 Jellyfin,并用一个脚本代替网页向导完成首次设置:设置语言、创建管理员、添加电影/剧集/音乐三个库、等扫描完成后列出入库的内容。重装系统或给家人再搭一台时,一条命令就能复现同样的配置。
先看清成品
在 box 上用官方镜像
jellyfin/jellyfin实测(服务器版本 12.2.0):
- 从全新状态运行脚本:完成向导,添加 3 个库,列出 8 个条目(1 部电影、2 集剧集、5 首音乐)。
- 再运行一次会跳过已经完成的步骤;密码错误时返回 401。
- 发现并修复了“扫描还没完成就去查,结果漏掉整个音乐库”的问题(见调试示例),修复后在全新容器上重新跑通。
docker compose config校验通过。
注意:Jellyfin 是免费开源的软件,硬件转码也不收费。媒体库里只放你有权使用的内容;不要安装或分享盗版插件和资源,不要把服务器开放给家庭以外的人共享内容。本文的测试素材都是用 ffmpeg 生成的测试图样和正弦波。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
docker-compose.yml |
官方镜像,指定运行用户,8096 端口,7359/udp 用于局域网发现,媒体目录只读 |
jf_setup.py |
向导 → 管理员 → 三个库 → 等待扫描 → 列出内容,可以重复运行 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| 硬件 | NAS 或迷你主机 | 需要转码时看 B 篇 |
| Docker | 带 Compose 插件 | 官方文档说明 DLNA 需要 host 网络 |
| 媒体目录 | Movies/、TV Shows/、Music/ |
命名可以参考 36-plex-A 的检查器(规则基本相同) |
| Python | 3.8 以上 | pip install requests |
文件结构
38-jellyfin-A-src/
├── docker-compose.yml
└── jf_setup.py
第 1 步:部署
docker-compose.yml:
# Jellyfin(官方镜像),适合 NAS 或迷你主机。首次打开 http://<主机IP>:8096 完成向导,或者用 jf_setup.py 自动完成
services:
jellyfin:
image: jellyfin/jellyfin
container_name: jellyfin
user: "1000:1000" # 对媒体目录有读权限的用户
ports:
- "8096:8096/tcp"
- "7359:7359/udp" # 局域网客户端自动发现
volumes:
- ./config:/config
- ./cache:/cache
- /srv/media:/media:ro # 里面放 Movies、TV Shows、Music 三个文件夹
restart: unless-stopped
docker compose up -d
curl http://127.0.0.1:8096/System/Info/Public # 实测:"Version":"12.2.0", "StartupWizardCompleted":false
第 2 步:自动设置脚本
jf_setup.py:
"""用 API 完成 Jellyfin 首次设置:初始化向导 → 管理员账号 → 添加电影、剧集、音乐库 → 等扫描完成 → 列出入库的内容。
和网页向导做的是同一件事,适合重装或批量部署时复现同样的配置。
环境变量:JF_URL(默认 http://127.0.0.1:8096)、JF_ADMIN、JF_PASSWORD
用法:python jf_setup.py (向导已完成时,会跳过初始化,直接登录)"""
import os, time
import requests
URL = os.environ.get("JF_URL", "http://127.0.0.1:8096").rstrip("/")
USER, PWD = os.environ.get("JF_ADMIN", "admin"), os.environ["JF_PASSWORD"]
CLIENT = 'MediaBrowser Client="jf_setup", Device="script", DeviceId="jf-setup-1", Version="1.0"'
LIBS = [("电影", "movies", "/media/Movies"), ("剧集", "tvshows", "/media/TV Shows"), ("音乐", "music", "/media/Music")]
def call(method, path, token=None, **kw):
auth = CLIENT + (f', Token="{token}"' if token else "")
r = requests.request(method, URL + path, headers={"Authorization": auth}, timeout=30, **kw)
if r.status_code >= 400: raise SystemExit(f"{method} {path} → HTTP {r.status_code} {r.text[:200]}")
return r.json() if r.content and "json" in r.headers.get("Content-Type", "") else None
def wizard():
call("POST", "/Startup/Configuration", json={"UICulture": "zh-CN", "MetadataCountryCode": "CN", "PreferredMetadataLanguage": "zh"})
call("GET", "/Startup/User") # 向导要求先读取一次默认用户
call("POST", "/Startup/User", json={"Name": USER, "Password": PWD})
call("POST", "/Startup/RemoteAccess", json={"EnableRemoteAccess": True, "EnableAutomaticPortMapping": False})
call("POST", "/Startup/Complete")
def main():
info = call("GET", "/System/Info/Public")
print(f"Jellyfin {info['Version']},向导已完成={info['StartupWizardCompleted']}")
if not info["StartupWizardCompleted"]: wizard(); print("✓ 初始化向导完成")
tok = call("POST", "/Users/AuthenticateByName", json={"Username": USER, "Pw": PWD})["AccessToken"]
have = {f["Name"] for f in call("GET", "/Library/VirtualFolders", tok)}
for name, ctype, path in LIBS:
if name in have: continue
call("POST", "/Library/VirtualFolders", tok, params={"name": name, "collectionType": ctype, "refreshLibrary": "true"},
json={"LibraryOptions": {"PathInfos": [{"Path": path}]}})
print(f"✓ 添加库 {name} ← {path}")
# 添加库时自带的扫描是在后台排队的,脚本直接查询会漏掉还没扫到的库(实测漏掉了整个音乐库)。
# 所以这里显式触发一次全库扫描:先等任务进入 Running,再等它回到 Idle。
call("POST", "/Library/Refresh", tok)
def state():
return next((t["State"] for t in call("GET", "/ScheduledTasks", tok) if t["Key"] == "RefreshLibrary"), "Idle")
for _ in range(20):
if state() != "Idle": break
time.sleep(0.5)
while state() != "Idle": time.sleep(2)
items = call("GET", "/Items", tok, params={"Recursive": "true", "IncludeItemTypes": "Movie,Episode,Audio", "Fields": "Path"})["Items"]
for it in sorted(items, key=lambda i: (i["Type"], i.get("Path", ""))):
print(f" {it['Type']:7} {it['Name']} ← {it.get('Path', '')}")
print(f"共 {len(items)} 个条目")
if __name__ == "__main__":
main()
$ JF_PASSWORD='换成你的强密码' python jf_setup.py
Jellyfin 12.2.0,向导已完成=False
✓ 初始化向导完成
✓ 添加库 电影 ← /media/Movies
✓ 添加库 剧集 ← /media/TV Shows
✓ 添加库 音乐 ← /media/Music
Audio Sine 220 Hz ← /media/Music/Synth Artist/Demo Album (2026)/01 - Sine 220 Hz.flac
…(共 5 首 Audio)
Episode Lab Show ← /media/TV Shows/Lab Show (2026)/Season 01/Lab Show (2026) - s01e01.mkv
Episode Lab Show ← /media/TV Shows/Lab Show (2026)/Season 01/Lab Show (2026) - s01e02.mkv
Movie Test Pattern (2026) ← /media/Movies/Test Pattern (2026)/Test Pattern (2026).mkv
共 8 个条目
检查点:浏览器打开 http://主机IP:8096,用管理员账号登录,能看到三个库。剧集名字显示成 “Lab Show”,是因为测试素材在网上查不到元数据;真实的剧集会刮削到每一集的标题。
调试示例:音乐库“消失”了
第一版添加完库以后,就轮询 RefreshLibrary 任务,等它变成 Idle 再列出内容。结果只列出了 3 个条目,一首歌都没有。用 API 查看:
音乐 music ['/media/Music'] Idle
0 []
库已经建好了,任务状态也是 Idle,但里面没有内容。手动调用一次 POST /Library/Refresh 之后,5 首歌就出现了。原因是:添加库时触发的扫描在后台排队,脚本查询的时候,任务还没开始,自然也是 Idle。修改方法:显式触发一次扫描,先等任务进入 Running,再等它回到 Idle。在全新容器上重新跑,8 个条目一次就全部列出来了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 库是空的 | 容器的运行用户读不到媒体目录 | docker exec jellyfin ls /media,检查 user: 设置 |
| 客户端发现不了服务器 | 7359/udp 没有映射 | 映射这个端口,或者手动输入服务器地址 |
| 中文元数据很少 | 地区和语言设置 | 脚本里已经设成 zh / CN,也可以在控制台里调整 |
| 脚本 401 | 密码不对 | 在网页里登录确认一下 |
模板规则
- 部署和配置都写成代码,可以重复运行。
- 媒体目录只读挂载。
- 管理员密码只通过环境变量传入。
项目资源
| 用途 | 地址 |
|---|---|
| 容器安装(官方) | https://jellyfin.org/docs/general/installation/container/ |
| Jellyfin API 文档 | https://api.jellyfin.org/ |
| Jellyfin 文档首页 | https://jellyfin.org/docs/ |
把网页向导换成脚本以后,第二台服务器也能保证配置一模一样。另外要记住一点:库建好了不等于已经扫描完。