给 Jellyfin 打开硬件转码(Intel QSV/VAAPI、AMD VAAPI、NVIDIA NVENC 等),并且真的转码一段视频来验证:脚本通过 API 修改编码设置,请求一个低码率的 HLS 分片,用 ffprobe 检查输出的分辨率,再从 FFmpeg 转码日志里读出实际用的是哪个编码器。这样就不用凭感觉判断“硬件加速到底开没开”。
先看清成品
- box 没有
/dev/dri,所以没有测过真正的硬件转码。 下面是在 box 上(Jellyfin 12.2.0)跑出的三种情况:
- 软件模式: 第 1 个分片是
h264 854x480,日志里的编码器是libx264。- 设成 vaapi 但没有设备: 转码失败,HTTP 500。日志里写的是
No device available for decoder: device type vaapi needed for codec h264。- 改回 none: 恢复正常。
- 容器日志列出 jellyfin-ffmpeg 带有
h264_vaapi、h264_qsv、h264_nvenc等编码器。- compose 叠加文件校验通过。
注意:Jellyfin 是免费开源的软件,硬件转码也不收费。媒体库里只放你有权使用的内容;不要安装或分享盗版插件和资源,不要把服务器开放给家庭以外的人共享内容。本文的测试素材都是用 ffmpeg 生成的测试图样和正弦波。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
jf_hwaccel.py |
show / set / test:修改加速类型,真实转码一段,读出编码器 |
docker-compose.hw.yml |
把渲染设备交给容器,并加入宿主机的 render 组 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| 硬件 | Intel 核显、AMD 或 NVIDIA 显卡 | 先用 36-plex-B 的 hw_check.sh 检查 |
| 宿主机 | 有 /dev/dri/renderD128 |
Intel 的检查方法见官方文档,可以用 jellyfin-ffmpeg 自带的 vainfo |
| 管理员 token | 控制台 → API 密钥 | 填到 JF_TOKEN |
文件结构
38-jellyfin-B-src/
├── jf_hwaccel.py
└── docker-compose.hw.yml
第 1 步:把设备交给容器
docker-compose.hw.yml:
# 叠加在 38-jellyfin-A 的 compose 上:把 Intel/AMD 核显的渲染节点交给容器
# group_add 填宿主机上 render 组的 GID:getent group render | cut -d: -f3
services:
jellyfin:
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
group_add:
- "989"
getent group render | cut -d: -f3 # 把输出的数字填进 group_add
docker compose -f docker-compose.yml -f docker-compose.hw.yml up -d
docker exec jellyfin /usr/lib/jellyfin-ffmpeg/vainfo --display drm --device /dev/dri/renderD128
官方文档说明:Intel 驱动显示为 iHD 时同时支持 QSV 和 VA-API;i965 只支持 VA-API,只应该用在 Broadwell 之前的平台。
第 2 步:修改设置并真实转码
jf_hwaccel.py:
"""Jellyfin 硬件转码:设置加速类型,然后真的转码一段视频,查看转码日志里实际用的是哪个编码器。
环境变量:JF_URL、JF_TOKEN(管理员 token,可以在 控制台 → API 密钥 里创建)
用法:
python jf_hwaccel.py show
python jf_hwaccel.py set vaapi|qsv|nvenc|none [/dev/dri/renderD128]
python jf_hwaccel.py test [日志目录] 转码第一部电影的第 1 个 HLS 分片(最大宽度 854、1.5 Mbps),检查分辨率和编码器
日志目录是 Jellyfin 配置目录下的 log(docker 挂载的 ./config/log),用来读 FFmpeg.Transcode-*.log。"""
import glob, json, os, subprocess, sys, tempfile, time, uuid
import requests
URL = os.environ.get("JF_URL", "http://127.0.0.1:8096").rstrip("/")
H = {"Authorization": f'MediaBrowser Token="{os.environ["JF_TOKEN"]}"'}
def api(method, path, **kw):
r = requests.request(method, URL + path, headers=H, timeout=60, **kw); r.raise_for_status()
return r.json() if "json" in r.headers.get("Content-Type", "") else r.content
def show():
c = api("GET", "/System/Configuration/encoding")
print({k: c[k] for k in ("HardwareAccelerationType", "VaapiDevice", "QsvDevice", "EnableHardwareEncoding", "HardwareDecodingCodecs")})
return c
def set_type(kind, device=None):
c = api("GET", "/System/Configuration/encoding")
if kind in ("vaapi", "qsv") and not os.path.exists(device or "/dev/dri/renderD128"):
print(f"! 本机没有 {device or '/dev/dri/renderD128'}。如果 Jellyfin 跑在容器里,要在容器内检查(docker exec jellyfin ls /dev/dri)")
c["HardwareAccelerationType"] = kind
if kind == "vaapi" and device: c["VaapiDevice"] = device
if kind == "qsv" and device: c["QsvDevice"] = device
c["HardwareDecodingCodecs"] = ["h264", "hevc", "vp9"] if kind != "none" else []
api("POST", "/System/Configuration/encoding", json=c); show()
def test(logdir=None):
item = api("GET", "/Items", params={"Recursive": "true", "IncludeItemTypes": "Movie", "Limit": 1})["Items"][0]
sid, t0 = uuid.uuid4().hex, time.time() # 每次用新的会话 ID:沿用旧 ID 时,Jellyfin 会复用上一次的转码结果
q = {"MediaSourceId": item["Id"], "VideoCodec": "h264", "AudioCodec": "aac", "VideoBitrate": 1500000,
"AudioBitrate": 128000, "MaxWidth": 854, "PlaySessionId": sid, "DeviceId": "hwtest"}
playlist = api("GET", f"/Videos/{item['Id']}/main.m3u8", params=q).decode()
seg = next(l for l in playlist.splitlines() if l and not l.startswith("#"))
try:
data = api("GET", f"/Videos/{item['Id']}/{seg}")
except requests.HTTPError as e:
raise SystemExit(f"转码失败(HTTP {e.response.status_code}):通常是加速类型和设备不匹配,去看最新的 FFmpeg.Transcode 日志")
with tempfile.NamedTemporaryFile(suffix=".ts") as f:
f.write(data); f.flush()
info = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height",
"-of", "json", f.name], capture_output=True, text=True, check=True).stdout)["streams"][0]
print(f"《{item['Name']}》第 1 个分片:{len(data)} 字节,{info['codec_name']} {info['width']}x{info['height']}")
if logdir:
new = [p for p in glob.glob(os.path.join(logdir, "FFmpeg.Transcode-*.log")) if os.path.getmtime(p) >= t0 - 1]
if not new: raise SystemExit("没有找到本次的转码日志:确认日志目录,并且这次确实发生了转码")
log = max(new, key=os.path.getmtime)
cmd = open(log, encoding="utf-8", errors="ignore").read(6000)
enc = next((e for e in ("h264_vaapi", "h264_qsv", "h264_nvenc", "h264_amf", "h264_v4l2m2m", "h264_rkmpp", "libx264") if f"-codec:v:0 {e}" in cmd or f"-c:v {e}" in cmd), "未识别")
print(f"转码日志 {os.path.basename(log)}:视频编码器 = {enc}" + ("(软件编码,没有用到硬件)" if enc == "libx264" else ""))
api("DELETE", "/Videos/ActiveEncodings", params={"deviceId": "hwtest", "playSessionId": sid})
if __name__ == "__main__":
cmd, *a = sys.argv[1:] or ["show"]
{"show": show, "set": set_type, "test": test}[cmd](*a)
box 上的实际输出:
$ python jf_hwaccel.py show
{'HardwareAccelerationType': 'none', 'VaapiDevice': '/dev/dri/renderD128', 'QsvDevice': '', 'EnableHardwareEncoding': True, 'HardwareDecodingCodecs': ['h264', 'vc1']}
$ python jf_hwaccel.py set vaapi /dev/dri/renderD128
! 本机没有 /dev/dri/renderD128。如果 Jellyfin 跑在容器里,要在容器内检查(docker exec jellyfin ls /dev/dri)
{'HardwareAccelerationType': 'vaapi', 'VaapiDevice': '/dev/dri/renderD128', 'QsvDevice': '', 'EnableHardwareEncoding': True, 'HardwareDecodingCodecs': ['h264', 'hevc', 'vp9']}
$ python jf_hwaccel.py test ./config/log
转码失败(HTTP 500):通常是加速类型和设备不匹配,去看最新的 FFmpeg.Transcode 日志
$ tail -4 ./config/log/FFmpeg.Transcode-*.log
… No device available for decoder: device type vaapi needed for codec h264.
… Hardware device setup failed for decoder: Generic error in an external library
$ python jf_hwaccel.py set none && python jf_hwaccel.py test ./config/log
《Test Pattern (2026)》第 1 个分片:271472 字节,h264 854x480
转码日志 FFmpeg.Transcode-2026-10-21_08-00-19_…log:视频编码器 = libx264(软件编码,没有用到硬件)
检查点:在有核显的机器上,set vaapi(或 qsv)之后运行 test,最后一行应该显示 h264_vaapi(或 h264_qsv)。这一步 box 上测不了。
调试示例:第二次测试读到的是旧日志
第一版的 test 固定使用 PlaySessionId=hwtest,日志则取最新修改的那个文件。切换到 vaapi 以后再测,结果还是 libx264,日志文件名的时间戳也和第一次一模一样(07-59-53)。原因是 Jellyfin 复用了同一个会话的转码结果,根本没有重新转码,脚本读到的是旧日志。修改方法:每次生成一个新的会话 ID,并且只读取测试开始之后产生的日志。改完再测,就如实得到了上面的 HTTP 500 和 vaapi 报错。另外,第一版的 MaxHeight=480 并没有生效,输出还是 1280x720;改成 MaxWidth=854 以后,输出才变成 854x480。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 开了硬件加速以后播放失败 | 容器里没有设备,或者没有 render 组权限 | 用 hw 叠加文件;group_add 填 render 组的 GID |
| 日志里还是 libx264 | 这次没有真正转码,或者复用了旧会话 | 换一个新会话再测,或者降低码率强制转码 |
| HDR 片子颜色发灰 | 没有开色调映射 | 按官方文档配置对应平台的 tonemapping |
| NVIDIA 方案 | 需要额外安装工具包 | 看官方 NVIDIA 页面 |
模板规则
- 验证要看转码日志里的编码器名称,不要只看设置页面。
- 每次测试都用新的会话 ID。
- 改设置之前,先确认设备已经交给了容器。
项目资源
| 用途 | 地址 |
|---|---|
| 硬件加速总览(官方) | https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/ |
| Intel GPU(官方) | https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel/ |
| ffprobe 文档 | https://ffmpeg.org/ffprobe.html |
| Jellyfin 文档首页 | https://jellyfin.org/docs/ |
硬件转码开没开,看转码日志里的编码器名字就知道,不用猜。这个脚本把“改设置、转码、读日志”三步串了起来。