From 6c8046bac8ab67a7fae4324347fc8aacad40cf33 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 18 Aug 2026 00:38:22 +0200 Subject: [PATCH] jupiter: VAAPI hardware transcoding for jellyfin, move heavy state off the eMMC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables hardware.graphics + intel-media-driver for the Apollo Lake's Gen9 iGPU (VAAPI only — QSV needs an insecure/EOL runtime on this chip) and adds jellyfin's service user to video/render for the DRI card node. 4K HDR still can't be tone-mapped on this hardware; those files need to direct-play or be kept as 1080p SDR. Also relocates podman's container storage and immich's postgres cluster to /mnt/data/AppData, after a deploy holding two ~9G closures at once filled the 29G eMMC and postgres died mid-write. Co-Authored-By: Claude Sonnet 5 --- hosts/jupiter/configuration.nix | 87 +++++++++++++++++++++++++++++++++ services/media/jellyfin.nix | 9 +++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/hosts/jupiter/configuration.nix b/hosts/jupiter/configuration.nix index 3672de7..454d3c3 100644 --- a/hosts/jupiter/configuration.nix +++ b/hosts/jupiter/configuration.nix @@ -51,6 +51,39 @@ # next value: acpi -> bios -> cold -> efi. boot.kernelParams = [ "reboot=pci" ]; + # ---- GPU (jellyfin hardware transcoding) ---- + # Apollo Lake N3450 / HD Graphics 500 (Gen9, pci 8086:5A85). The i915 KERNEL + # driver binds on its own — /dev/dri/{card1,renderD128} exist without this — + # but the libva USERSPACE driver only ships when hardware.graphics is on, and + # nothing else here pulled it in. Without it VAAPI init fails with "unknown + # libva error" and jellyfin-ffmpeg exits 251 on EVERY transcode, which the + # client shows as generic playback failure: the server log only says "FFmpeg + # exited with code 251", never that a driver is missing. Verified on the box: + # the same h264_vaapi encode goes 251 -> 0 once iHD is on LIBVA_DRIVERS_PATH. + # + # iHD (intel-media-driver) is the right one for Gen9; i965 is for Gen8 and + # older. Note the render node is 0666 but card1 is 0660 root:video, so the + # group membership in services/media/jellyfin.nix matters for the card node. + hardware.graphics = { + enable = true; + extraPackages = [ pkgs.intel-media-driver ]; + }; + # ⚠️ This buys VAAPI only — jellyfin must be set to VAAPI, NOT QSV, in its + # web UI (Dashboard -> Playback -> Transcoding). QSV needs an MFX runtime on + # top of the libva driver: ffmpeg's `-init_hw_device qsv=qs@va` dies with + # "Error creating a MFX session: -9" -> exit 171, the SECOND failure hiding + # behind the first (fixing the missing driver only moved 251 -> 171). + # There is no good way to provide it here: vpl-gpu-rt is Gen12+, and the + # Gen9 runtime `intel-media-sdk` is marked INSECURE in nixpkgs (EOL, 5 CVEs + # incl. local privilege escalation) — not worth it when VAAPI does the same + # job on this chip at ~3.5x realtime for 1080p->720p. + # + # Also: 4K HDR (the 2160p HEVC/DV remuxes) can NOT be tone-mapped here. + # tonemap_opencl needs OpenCL, which has no platform on this box, and + # tonemap_vaapi is Gen11+ — both fail. Only a plain scale_vaapi=format=nv12 + # succeeds, which drops HDR without tone-mapping (washed-out picture). + # Those files need to direct-play, or be kept as 1080p SDR versions. + # ---- NAS data array ---- # Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T). # Mounted, NOT formatted; kept out of disko so it is never wiped. @@ -75,6 +108,60 @@ # still refuse to start rather than writing to the eMMC. systemd.enableEmergencyMode = false; + # ---- Heavy state moved off the eMMC ---- + # A deploy holds TWO full closures (~9G each) on a 29G disk at once, so the + # OS disk has no room for state that grows on its own. 2026-08-09: it hit 0 + # bytes free with both gen 39 and gen 40 resident, and postgres died on + # "No space left on device" — note ext4 reserves 5% for root, so non-root + # services see zero while df still shows ~300M free. + # + # Paths live under /mnt/data/AppData like every other service's state. Both + # settings below are jupiter-only on purpose: services/containers.nix stays + # engine- and host-agnostic (mercury runs pihole on podman with no array). + + # podman: CI images dominate and keep growing — the gitea runner's + # act-latest is 1.7G, and the act-22.04 label in services/dev/gitea.nix + # pulls another ~1.7G the first time a job requests it. + # runroot stays on /run: it is per-boot tmpfs state, not a growing store. + virtualisation.containers.storage.settings.storage = { + driver = "overlay"; + graphroot = "/mnt/data/AppData/containers/storage"; + runroot = "/run/containers/storage"; + }; + + # immich's postgres cluster. Version component mirrors the upstream default + # (`/var/lib/postgresql/${psqlSchema}`) so a major bump gets its own dir + # instead of silently reusing the old cluster's files. + # ⚠️ This puts the DB in the SAME failure domain as the photos it indexes: + # /mnt/data is RAID0, so either 16TB disk now loses both, where before an + # eMMC failure and an array failure each took only one. Chosen deliberately + # — the two are useless apart — but neither is backed up. + services.postgresql.dataDir = + "/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}"; + + # /mnt/data/AppData is drwx--x--- darman:users, so postgres needs group + # "users" just to TRAVERSE into its own dataDir — exactly the reason immich + # has the same line. The cluster dir itself keeps the mode it was initdb'd + # with (0750 postgres:postgres) — postgres only accepts 0700, or 0750 when + # the cluster was created with group access, and refuses to start otherwise. + users.users.postgres.extraGroups = [ "users" ]; + + # Neither path is under /var/lib, so no module creates it: the postgresql + # module's own tmpfiles entry only adjusts a dataDir that already exists, + # the same way immich's mediaLocation rule does. + systemd.tmpfiles.rules = [ + "d /mnt/data/AppData/postgresql 0750 postgres postgres -" + "d /mnt/data/AppData/containers 0700 root root -" + ]; + + # graphroot is not a systemd path dependency the way dataDir is, so nothing + # derives a mount ordering from it. Without these, podman would recreate an + # empty store on the eMMC under the mountpoint when the array is late or + # absent, and the runner would re-pull every image into it. + # (podman-clonarr already carries this from services/media/clonarr.nix.) + systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; + systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; + # ---- Caddy vhosts (LAN) ---- # Reached via pihole local-DNS names -> jupiter IP. services.caddy.virtualHosts = { diff --git a/services/media/jellyfin.nix b/services/media/jellyfin.nix index 9bc0c55..798b919 100644 --- a/services/media/jellyfin.nix +++ b/services/media/jellyfin.nix @@ -6,7 +6,14 @@ dataDir = "/mnt/data/AppData/jellyfin"; cacheDir = "/mnt/data/AppData/jellyfin/cache"; }; - users.users.jellyfin.extraGroups = [ "users" ]; + # "users" so the shared library stays readable (see the UMask note below); + # "video"/"render" for the DRI nodes used by hardware transcoding. renderD128 + # happens to be 0666 so VAAPI alone would work without this, but card1 is + # 0660 root:video — and neither mode is guaranteed, so don't rely on it. The + # groups are harmless on a host with no GPU: they exist regardless, and this + # module stays host-agnostic (the DRIVER is enabled per-host, e.g. jupiter's + # hardware.graphics + intel-media-driver). + users.users.jellyfin.extraGroups = [ "users" "video" "render" ]; # The upstream module hardcodes UMask=0077 — root cause of jellyfin writing # trickplay thumbnails into stray new show folders it invented itself,