nixos/jellyfin: add hardware transcode options to module (#457987)

This commit is contained in:
Michael Daniels 2025-12-23 16:36:33 +00:00 committed by GitHub
commit 0430acab84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 464 additions and 12 deletions

View file

@ -8,14 +8,80 @@
let
inherit (lib)
mkIf
mkDefault
getExe
maintainers
mkEnableOption
mkOption
mkPackageOption
boolToString
escapeXML
nameValuePair
optionalString
concatMapStringsSep
escapeShellArg
literalExpression
;
inherit (lib.types)
bool
enum
ints
nullOr
path
str
submodule
;
inherit (lib.types) str path bool;
cfg = config.services.jellyfin;
filteredDecodingCodecs = builtins.filter (
c: c != "hevcRExt10bit" && c != "hevcRExt12bit" && cfg.transcoding.hardwareDecodingCodecs.${c}
) (builtins.attrNames cfg.transcoding.hardwareDecodingCodecs);
encodingXmlText = ''
<?xml version="1.0" encoding="utf-8"?>
<EncodingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<HardwareAccelerationType>${cfg.hardwareAcceleration.type}</HardwareAccelerationType>
${optionalString (
cfg.hardwareAcceleration.type == "vaapi" && cfg.hardwareAcceleration.device != null
) "<VaapiDevice>${escapeXML cfg.hardwareAcceleration.device}</VaapiDevice>"}
${optionalString (
cfg.hardwareAcceleration.type == "qsv" && cfg.hardwareAcceleration.device != null
) "<OpenclDevice>${escapeXML cfg.hardwareAcceleration.device}</OpenclDevice>"}
<EncodingThreadCount>${
if cfg.transcoding.threadCount != null then toString cfg.transcoding.threadCount else "-1"
}</EncodingThreadCount>
<EnableThrottling>${boolToString cfg.transcoding.throttleTranscoding}</EnableThrottling>
<EnableTonemapping>${boolToString cfg.transcoding.enableToneMapping}</EnableTonemapping>
<EnableSubtitleExtraction>${boolToString cfg.transcoding.enableSubtitleExtraction}</EnableSubtitleExtraction>
<H264Crf>${toString cfg.transcoding.h264Crf}</H264Crf>
<H265Crf>${toString cfg.transcoding.h265Crf}</H265Crf>
<EnableHardwareEncoding>${boolToString cfg.transcoding.enableHardwareEncoding}</EnableHardwareEncoding>
<AllowHevcEncoding>${boolToString cfg.transcoding.hardwareEncodingCodecs.hevc}</AllowHevcEncoding>
<AllowAv1Encoding>${boolToString cfg.transcoding.hardwareEncodingCodecs.av1}</AllowAv1Encoding>
<EnableIntelLowPowerH264HwEncoder>${boolToString cfg.transcoding.enableIntelLowPowerEncoding}</EnableIntelLowPowerH264HwEncoder>
<EnableIntelLowPowerHevcHwEncoder>${boolToString cfg.transcoding.enableIntelLowPowerEncoding}</EnableIntelLowPowerHevcHwEncoder>
<EnableDecodingColorDepth10HevcRext>${boolToString cfg.transcoding.hardwareDecodingCodecs.hevcRExt10bit}</EnableDecodingColorDepth10HevcRext>
<EnableDecodingColorDepth12HevcRext>${boolToString cfg.transcoding.hardwareDecodingCodecs.hevcRExt12bit}</EnableDecodingColorDepth12HevcRext>
<HardwareDecodingCodecs>
${concatMapStringsSep "\n " (
codec: "<string>${escapeXML codec}</string>"
) filteredDecodingCodecs}
</HardwareDecodingCodecs>
</EncodingOptions>
'';
encodingXmlFile = pkgs.writeText "encoding.xml" encodingXmlText;
codecListToType =
desc: list:
submodule {
options = builtins.listToAttrs (
map (
name:
nameValuePair name (mkOption {
type = bool;
default = false;
description = "Enable ${desc} for ${name} codec.";
})
) list
);
};
in
{
options = {
@ -48,7 +114,7 @@ in
configDir = mkOption {
type = path;
default = "${cfg.dataDir}/config";
defaultText = "\${cfg.dataDir}/config";
defaultText = literalExpression ''"''${cfg.dataDir}/config"'';
description = ''
Directory containing the server configuration files,
passed with `--configdir` see [configuration-directory](https://jellyfin.org/docs/general/administration/configuration/#configuration-directory)
@ -67,7 +133,7 @@ in
logDir = mkOption {
type = path;
default = "${cfg.dataDir}/log";
defaultText = "\${cfg.dataDir}/log";
defaultText = literalExpression ''"''${cfg.dataDir}/log"'';
description = ''
Directory where the Jellyfin logs will be stored,
passed with `--logdir` see [#log-directory](https://jellyfin.org/docs/general/administration/configuration/#log-directory)
@ -83,10 +149,216 @@ in
only be used if they are unchanged, see [Port Bindings](https://jellyfin.org/docs/general/networking/#port-bindings).
'';
};
hardwareAcceleration = {
enable = mkEnableOption "hardware acceleration for video transcoding";
device = mkOption {
type = nullOr path;
default = null;
example = "/dev/dri/renderD128";
description = ''
Path to the hardware acceleration device that Jellyfin should use.
For obscure configurations, additional devices can be added via
{option}`systemd.services.jellyfin.serviceConfig.DeviceAllow`.
'';
};
# see MediaBrowser.Model/Entities/HardwareAccelerationType.cs in jellyfin source
type = mkOption {
type = enum [
"none"
"amf"
"qsv"
"nvenc"
"v4l2m2m"
"vaapi"
# videotoolbox is MacOS-only
"rkmpp"
];
default = "none";
description = ''
The method of hardware acceleration. See [Hardware Acceleration](https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration) for more details.
'';
};
};
forceEncodingConfig = mkOption {
type = bool;
default = false;
description = ''
Whether to overwrite Jellyfin's `encoding.xml` configuration file on each service start.
When enabled, the encoding configuration specified in {option}`services.jellyfin.transcoding`
and {option}`services.jellyfin.hardwareAcceleration` will be applied on every service restart.
A backup of the existing `encoding.xml` will be created at `encoding.xml.backup-$timestamp`.
::: {.warning}
Enabling this option means that any changes made to transcoding settings through
Jellyfin's web dashboard will be lost on the next service restart. The NixOS configuration
becomes the single source of truth for encoding settings.
:::
When disabled (the default), the encoding configuration is only written if no `encoding.xml`
exists yet. This allows settings to be changed through Jellyfin's web dashboard and persist
across restarts, but means the NixOS configuration options will be ignored after the initial setup.
'';
};
transcoding = {
maxConcurrentStreams = mkOption {
type = nullOr ints.positive;
default = null;
example = 2;
description = ''
Maximum number of concurrent transcoding streams.
Set to null for unlimited (limited by hardware capabilities).
'';
};
enableToneMapping = mkOption {
type = bool;
default = true;
description = ''
Enable tone mapping when transcoding HDR content.
'';
};
enableSubtitleExtraction = mkOption {
type = bool;
default = true;
description = ''
Embedded subtitles can be extracted from videos and delivered to clients in plain text, in order to help prevent video transcoding. On some systems this can take a long time and cause video playback to stall during the extraction process. Disable this to have embedded subtitles burned in with video transcoding when they are not natively supported by the client device.
'';
};
throttleTranscoding = mkOption {
type = bool;
default = false;
description = ''
When a transcode or remux gets far enough ahead from the current playback position, pause the process so it will consume fewer resources. This is most useful when watching without seeking often. Turn this off if you experience playback issues.
'';
};
threadCount = mkOption {
type = nullOr ints.positive;
default = null;
example = 4;
description = ''
Number of threads to use when transcoding.
Set to null to use automatic detection.
'';
};
hardwareDecodingCodecs = mkOption {
type = codecListToType "hardware decoding" [
"h264"
"hevc"
"mpeg2"
"vc1"
"vp8"
"vp9"
"av1"
"hevc10bit"
"hevcRExt10bit"
"hevcRExt12bit"
];
default = { };
example = {
vp9 = true;
h264 = true;
};
description = ''
Which codecs to enable for hardware decoding.
'';
};
hardwareEncodingCodecs = mkOption {
type = codecListToType "hardware encoding" [
"hevc"
"av1"
];
default = { };
example = {
av1 = true;
};
description = ''
Which codecs to enable for hardware encoding. h264 is always enabled.
'';
};
encodingPreset = mkOption {
type = enum [
"auto"
"veryslow"
"slower"
"slow"
"medium"
"fast"
"faster"
"veryfast"
"superfast"
"ultrafast"
];
default = "auto";
description = ''
Encoder preset for transcoding.
Lower presets sacrifice quality for speed, higher presets optimize quality.
'';
};
deleteSegments = mkOption {
type = bool;
default = true;
description = ''
Delete transcoding segments when finished.
'';
};
h264Crf = mkOption {
type = ints.between 0 51;
default = 23;
description = ''
Constant Rate Factor (CRF) for H.264 encoding. Lower values result in better quality. Range: 0-51.
'';
};
h265Crf = mkOption {
type = ints.between 0 51;
default = 28;
description = ''
Constant Rate Factor (CRF) for H.265 encoding. Lower values result in better quality. Range: 0-51.
'';
};
enableHardwareEncoding = mkOption {
type = bool;
default = false;
description = ''
Enable hardware encoding for video transcoding.
'';
};
enableIntelLowPowerEncoding = mkOption {
type = bool;
default = false;
description = ''
Enable low-power encoding mode for Intel Quick Sync Video.
Requires i915 HuC firmware to be configured.
'';
};
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.hardwareAcceleration.enable -> cfg.hardwareAcceleration.device != null;
message = "services.jellyfin.hardwareAcceleration.device cannot be null when hardware acceleration is enabled.";
}
];
systemd = {
tmpfiles.settings.jellyfinDirs = {
"${cfg.dataDir}"."d" = {
@ -112,6 +384,47 @@ in
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
preStart = mkIf cfg.hardwareAcceleration.enable (
''
configDir=${escapeShellArg cfg.configDir}
encodingXml="$configDir/encoding.xml"
''
+ (
if cfg.forceEncodingConfig then
''
if [[ -e $encodingXml ]]; then
# this intentionally removes trailing newlines
currentText="$(<"$encodingXml")"
configuredText="$(<${encodingXmlFile})"
if [[ $currentText == "$configuredText" ]]; then
# don't need to do anything
exit 0
else
encodingXmlBackup="$configDir/encoding.xml.backup-$(date -u +"%FT%H_%M_%SZ")"
mv --update=none-fail -T "$encodingXml" "$encodingXmlBackup"
fi
fi
cp --update=none-fail -T ${encodingXmlFile} "$encodingXml"
chmod u+w "$encodingXml"
''
else
''
if [[ -e $encodingXml ]]; then
# this intentionally removes trailing newlines
currentText="$(<"$encodingXml")"
configuredText="$(<${encodingXmlFile})"
if [[ $currentText != "$configuredText" ]]; then
echo "WARN: $encodingXml already exists and is different from the configured settings. transcoding options NOT applied." >&2
echo "WARN: Set config.services.jellyfin.forceEncodingConfig = true to override." >&2
fi
else
cp --update=none-fail -T ${encodingXmlFile} "$encodingXml"
chmod u+w "$encodingXml"
fi
''
)
);
# This is mostly follows: https://github.com/jellyfin/jellyfin/blob/master/fedora/jellyfin.service
# Upstream also disable some hardenings when running in LXC, we do the same with the isContainer option
serviceConfig = {
@ -154,7 +467,10 @@ in
LockPersonality = true;
PrivateTmp = !config.boot.isContainer;
# needed for hardware acceleration
PrivateDevices = false;
# PrivateDevices defaults to false for backwards compatibility - users may have
# hardware acceleration set up outside of NixOS configuration
PrivateDevices = mkDefault false;
DeviceAllow = mkIf cfg.hardwareAcceleration.enable [ "${cfg.hardwareAcceleration.device} rw" ];
PrivateUsers = true;
RemoveIPC = true;

View file

@ -4,11 +4,65 @@
name = "jellyfin";
meta.maintainers = with lib.maintainers; [ minijackson ];
nodes.machine = {
services.jellyfin.enable = true;
environment.systemPackages = with pkgs; [ ffmpeg ];
# Jellyfin fails to start if the data dir doesn't have at least 2GiB of free space
virtualisation.diskSize = 3 * 1024;
nodes = {
machine = {
services.jellyfin.enable = true;
environment.systemPackages = with pkgs; [ ffmpeg ];
# Jellyfin fails to start if the data dir doesn't have at least 2GiB of free space
virtualisation.diskSize = 3 * 1024;
};
machineWithTranscoding = {
services.jellyfin = {
enable = true;
hardwareAcceleration = {
enable = true;
type = "vaapi";
device = "/dev/dri/renderD128";
};
transcoding = {
enableToneMapping = false;
threadCount = 4;
enableHardwareEncoding = true;
enableSubtitleExtraction = false;
deleteSegments = true;
h264Crf = 23;
h265Crf = 26;
throttleTranscoding = false;
enableIntelLowPowerEncoding = true;
hardwareDecodingCodecs = {
h264 = true;
hevc = true;
vp9 = true;
hevcRExt10bit = true;
hevcRExt12bit = true;
};
hardwareEncodingCodecs = {
hevc = true;
av1 = true;
};
};
};
environment.systemPackages = with pkgs; [ ffmpeg ];
virtualisation.diskSize = 3 * 1024;
};
machineWithForceConfig = {
services.jellyfin = {
enable = true;
forceEncodingConfig = true;
hardwareAcceleration = {
enable = true;
type = "vaapi";
device = "/dev/dri/renderD128";
};
transcoding = {
threadCount = 2;
};
};
environment.systemPackages = with pkgs; [ ffmpeg ];
virtualisation.diskSize = 3 * 1024;
};
};
# Documentation of the Jellyfin API: https://api.jellyfin.org/
@ -28,13 +82,46 @@
import json
from urllib.parse import urlencode
machine.wait_for_unit("jellyfin.service")
machine.wait_for_open_port(8096)
machine.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'")
def wait_for_jellyfin(machine):
machine.wait_for_unit("jellyfin.service")
machine.wait_for_open_port(8096)
machine.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'")
wait_for_jellyfin(machine)
machine.succeed("curl --fail http://localhost:8096/")
machine.wait_until_succeeds("curl --fail http://localhost:8096/health | grep Healthy")
# Test hardware acceleration configuration
with subtest("Hardware acceleration configuration"):
wait_for_jellyfin(machineWithTranscoding)
# Check device access
machineWithTranscoding.succeed("systemctl show jellyfin.service --property=DeviceAllow | grep '/dev/dri/renderD128 rw'")
# Test forceEncodingConfig backup functionality
with subtest("Force encoding config creates backup"):
wait_for_jellyfin(machineWithForceConfig)
# Verify encoding.xml exists
machineWithForceConfig.succeed("test -f /var/lib/jellyfin/config/encoding.xml")
# Stop service before modifying config
machineWithForceConfig.succeed("systemctl stop jellyfin.service")
# Create a marker in the current encoding.xml to verify backup works
machineWithForceConfig.succeed("echo '<!-- MARKER -->' > /var/lib/jellyfin/config/encoding.xml")
# Restart the service to trigger the backup
machineWithForceConfig.succeed("systemctl restart jellyfin.service")
wait_for_jellyfin(machineWithForceConfig)
# Verify backup was created with the marker (uses glob pattern for timestamped backup)
machineWithForceConfig.succeed("grep -q 'MARKER' /var/lib/jellyfin/config/encoding.xml.backup-*")
# Verify the new encoding.xml does not have the marker (was overwritten)
machineWithForceConfig.fail("grep -q 'MARKER' /var/lib/jellyfin/config/encoding.xml")
auth_header = 'MediaBrowser Client="NixOS Integration Tests", DeviceId="1337", Device="Apple II", Version="20.09"'
@ -48,6 +135,55 @@
else:
return f"curl --fail -X post 'http://localhost:8096{path}' -H 'X-Emby-Authorization:{auth_header}'"
# Test dashboard-based configuration verification
with subtest("Dashboard configuration verification"):
# Complete setup and get admin token
machineWithTranscoding.wait_until_succeeds(api_get("/Startup/Configuration"))
machineWithTranscoding.succeed(api_get("/Startup/FirstUser"))
machineWithTranscoding.succeed(api_post("/Startup/Complete"))
auth_result = json.loads(machineWithTranscoding.succeed(
api_post("/Users/AuthenticateByName", "${payloads.auth}")
))
token = auth_result["AccessToken"]
def api_get_with_token(path):
return f"curl --fail 'http://localhost:8096{path}' -H 'X-Emby-Authorization:MediaBrowser Client=\"Test\", DeviceId=\"test\", Token={token}'"
# Get encoding config and verify key settings
config = json.loads(machineWithTranscoding.succeed(api_get_with_token("/System/Configuration/encoding")))
# Main hardware acceleration settings verification
assert config.get("HardwareAccelerationType") == "vaapi", f"Hardware acceleration type: expected 'vaapi', got '{config.get('HardwareAccelerationType')}'"
assert config.get("VaapiDevice") == "/dev/dri/renderD128", f"VAAPI device: expected '/dev/dri/renderD128', got '{config.get('VaapiDevice')}'"
assert config.get("EncodingThreadCount") == 4, f"Thread count: expected 4, got '{config.get('EncodingThreadCount')}'"
assert config.get("EnableHardwareEncoding") == True, f"Hardware encoding: expected True, got '{config.get('EnableHardwareEncoding')}'"
# Transcoding settings verification
assert config.get("H264Crf") == 23, f"H264 CRF: expected 23, got '{config.get('H264Crf')}'"
assert config.get("H265Crf") == 26, f"H265 CRF: expected 26, got '{config.get('H265Crf')}'"
assert config.get("EnableTonemapping") == False, f"Tone mapping: expected False, got '{config.get('EnableTonemapping')}'"
assert config.get("EnableThrottling") == False, f"Throttling: expected False, got '{config.get('EnableThrottling')}'"
assert config.get("EnableSubtitleExtraction") == False, f"Subtitle extraction: expected False, got '{config.get('EnableSubtitleExtraction')}'"
# Hardware encoding codecs verification
assert config.get("AllowHevcEncoding") == True, f"Allow HEVC encoding: expected True, got '{config.get('AllowHevcEncoding')}'"
assert config.get("AllowAv1Encoding") == True, f"Allow AV1 encoding: expected True, got '{config.get('AllowAv1Encoding')}'"
# Intel low power encoding verification
assert config.get("EnableIntelLowPowerH264HwEncoder") == True, f"Intel low power H264: expected True, got '{config.get('EnableIntelLowPowerH264HwEncoder')}'"
assert config.get("EnableIntelLowPowerHevcHwEncoder") == True, f"Intel low power HEVC: expected True, got '{config.get('EnableIntelLowPowerHevcHwEncoder')}'"
# HEVC RExt color depth verification
assert config.get("EnableDecodingColorDepth10HevcRext") == True, f"HEVC RExt 10bit: expected True, got '{config.get('EnableDecodingColorDepth10HevcRext')}'"
assert config.get("EnableDecodingColorDepth12HevcRext") == True, f"HEVC RExt 12bit: expected True, got '{config.get('EnableDecodingColorDepth12HevcRext')}'"
# Hardware decoding codecs verification
decoding_codecs = config.get("HardwareDecodingCodecs", [])
assert "h264" in decoding_codecs, f"h264 should be in HardwareDecodingCodecs, got {decoding_codecs}"
assert "hevc" in decoding_codecs, f"hevc should be in HardwareDecodingCodecs, got {decoding_codecs}"
assert "vp9" in decoding_codecs, f"vp9 should be in HardwareDecodingCodecs, got {decoding_codecs}"
with machine.nested("Wizard completes"):
machine.wait_until_succeeds(api_get("/Startup/Configuration"))