Files
nixos/docs/superpowers/plans/2026-07-26-jellyfin-hw-transcoding.md
2026-07-26 14:31:20 +02:00

6.4 KiB

Jellyfin Hardware Transcoding (jupiter) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give jupiter's Jellyfin service access to the Intel iGPU's VAAPI render node so Quick Sync hardware transcoding can be enabled, instead of every transcode falling back to CPU.

Architecture: One NixOS module change (modules/environments/jellyfin/default.nix) grants the jellyfin systemd service supplementary access to the video/render groups and installs libva-utils for verification. This is declarative and build-verifiable from the Mac. Enabling Quick Sync inside Jellyfin's own dashboard, and the on-machine verification, is a manual step run by the user on jupiter after deploy — the NixOS module has no option for it and this environment's convention is that the assistant never SSHes into jupiter directly (see docs/superpowers/specs/2026-07-26-jellyfin-hw-transcoding.md).

Tech Stack: NixOS (flake-parts), nixpkgs services.jellyfin module, VAAPI/intel-media-driver, libva-utils.

Global Constraints

  • No SSH from the assistant into jupiter — all on-machine commands are given to the user to run and paste back.
  • Follow the existing profile pattern in modules/environments/jellyfin/default.nix (config = lib.mkIf cfg.enable { ... }); don't introduce a new toggle option — hardcode the hardware-acceleration wiring on, per the approved spec.
  • Verify locally via nix eval / nix build before asking the user to deploy.

Task 1: Grant Jellyfin access to the iGPU and verify the build

Files:

  • Modify: modules/environments/jellyfin/default.nix

Interfaces:

  • Produces: systemd.services.jellyfin.serviceConfig.SupplementaryGroups = [ "video" "render" ]; — verified via nix eval in Step 2.

  • Step 1: Add the device-access config and libva-utils package

Read the current file first (modules/environments/jellyfin/default.nix), then edit the config = lib.mkIf cfg.enable { ... } block so it reads:

  config = lib.mkIf cfg.enable {
    services.jellyfin = {
      enable = true;
      openFirewall = true;
    };

    environment.systemPackages = [ pkgs.libva-utils ];

    my.homepage.services = [
      {
        group = "Media";
        name = "Jellyfin";
        description = "Media server";
        href = "http://${hostName}:${toString port}";
        icon = "jellyfin.png";
      }
    ];

    systemd.services.jellyfin = {
      after = [ "network-online.target" ];
      serviceConfig.SupplementaryGroups = [
        "video"
        "render"
      ];
    };
  };

Note the two existing systemd.services.jellyfin keys (after) and the new serviceConfig.SupplementaryGroups now live in the same attrset — don't create a second systemd.services.jellyfin = { ... } block, it would overwrite the first.

  • Step 2: Verify the rendered config with nix eval

Run (from the repo root on the Mac):

nix eval '.#nixosConfigurations.jupiter.config.systemd.services.jellyfin.serviceConfig.SupplementaryGroups' \
  --extra-experimental-features 'nix-command flakes'

Expected output: [ "video" "render" ]

  • Step 3: Verify the machine still builds

Run:

nix build '.#nixosConfigurations.jupiter.config.system.build.toplevel' \
  --extra-experimental-features 'nix-command flakes' --no-link

Expected: build succeeds with no errors (may take a while; watch for any evaluation error mentioning jellyfin or libva-utils).

  • Step 4: Format and commit
nixfmt-rfc-style modules/environments/jellyfin/default.nix
git add modules/environments/jellyfin/default.nix
git commit -m "feat(jellyfin): grant iGPU access for Quick Sync hardware transcoding"

Task 2: Deploy on jupiter and enable Quick Sync (user-executed)

Files: none (on-machine deploy + Jellyfin dashboard UI)

Interfaces:

  • Consumes: the SupplementaryGroups change from Task 1, already merged into the flake.

These steps run on jupiter, by the user — paste the output back so we can confirm each one before moving to the next.

  • Step 1: Deploy
sudo nixos-rebuild switch --flake '.#jupiter'

Expected: switch succeeds, no errors mentioning jellyfin.

  • Step 2: Confirm the service picked up the new groups
systemctl show jellyfin -p SupplementaryGroups
systemctl status jellyfin --no-pager

Expected: SupplementaryGroups=video render (order may vary) and the service is active (running).

  • Step 3: Confirm VAAPI driver loads
vainfo

Expected: output starts with something like vainfo: VA-API version: 1.x and Driver version: Intel iHD driver, followed by a list of supported VAProfiles/VAEntrypoints (e.g. VAProfileH264Main : VAEntrypointVLD, VAEntrypointEncSlice).

If this instead prints a permissions or "no VA display" error, paste it back — that means the group grant isn't reaching the process and Task 1 needs a follow-up fix (e.g. the jellyfin service may be more sandboxed than expected, requiring an explicit DeviceAllow=char-drm rw in serviceConfig as well).

  • Step 4: Enable Quick Sync in the Jellyfin dashboard

In the Jellyfin web UI:

  1. Dashboard → Playback.
  2. Hardware acceleration: Intel QuickSync (QSV).
  3. VA-API device: /dev/dri/renderD128.
  4. Enable hardware decoding for the codecs your library uses (H264 at minimum).
  5. If the library has HDR content, enable tone-mapping.
  6. Save.
  • Step 5: Functional check

Play a file that requires transcoding (or force a lower quality in the client's playback settings to trigger one), then:

journalctl -u jellyfin -n 50 --no-pager

Look for a line referencing qsv or vaapi in the transcode command. Separately, watch CPU usage (htop) during playback — it should stay low on the core doing the transcode, rather than pegging at 100%, since the iGPU is now doing the encode/decode work.

Self-Review Notes

  • Spec coverage: NixOS change (Task 1) ✓, manual dashboard step (Task 2 Step 4) ✓, verification via vainfo/build (Task 1 Step 2-3, Task 2 Step 3) ✓, functional check (Task 2 Step 5) ✓. Toggle option explicitly excluded per approved spec — not present, correctly.
  • No placeholders — every step has literal commands/code.
  • SupplementaryGroups key/value matches exactly between Task 1 (produced) and Task 2 (consumed/checked).