云端部署

在 NAS 或迷你主机上用 Docker 部署 Jellyfin,并写一个“命名检查器”

在 NAS 或迷你主机上用 Docker 部署 Jellyfin,并用一个脚本代替网页向导完成首次设置:设置语言、创建管理员、添加电影/剧集/音乐三个库、等扫描完成后列出入库的内容。重装系统或给家人再搭一台时,一条命令就能复现同样的配置。

BINBHO · 图文教程

在 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/

把网页向导换成脚本以后,第二台服务器也能保证配置一模一样。另外要记住一点:库建好了不等于已经扫描完。