Unverified Commit 5c13ef0f authored by Simon Gardling's avatar Simon Gardling
Browse files

nixos/jellyfin: add hardware transcode options to module

parent 42363c7f
Loading
Loading
Loading
Loading
+320 −4
Original line number Diff line number Diff line
@@ -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 = {
@@ -149,7 +462,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;

+144 −8
Original line number Diff line number Diff line
@@ -4,13 +4,67 @@
  name = "jellyfin";
  meta.maintainers = with lib.maintainers; [ minijackson ];

  nodes.machine = {
  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/
  # Beware, this link can be resource intensive
  testScript =
@@ -28,13 +82,46 @@
      import json
      from urllib.parse import urlencode

      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"))