效率工具

给 Jellyfin 打开硬件转码(Intel QSV/VAAPI、AMD VAAPI、NVIDIA NVENC 等)

本文通过 Jellyfin API 配置硬件加速,并实际请求转码分片、检查视频分辨率和 FFmpeg 编码器日志,验证是否真正使用 QSV、VA-API 或 NVENC。

BINBHO · 图文教程

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

硬件转码开没开,看转码日志里的编码器名字就知道,不用猜。这个脚本把“改设置、转码、读日志”三步串了起来。