jupiter: VAAPI hardware transcoding for jellyfin, move heavy state off the eMMC
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 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,39 @@
|
|||||||
# next value: acpi -> bios -> cold -> efi.
|
# next value: acpi -> bios -> cold -> efi.
|
||||||
boot.kernelParams = [ "reboot=pci" ];
|
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 ----
|
# ---- NAS data array ----
|
||||||
# Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T).
|
# Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T).
|
||||||
# Mounted, NOT formatted; kept out of disko so it is never wiped.
|
# 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.
|
# still refuse to start rather than writing to the eMMC.
|
||||||
systemd.enableEmergencyMode = false;
|
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) ----
|
# ---- Caddy vhosts (LAN) ----
|
||||||
# Reached via pihole local-DNS names -> jupiter IP.
|
# Reached via pihole local-DNS names -> jupiter IP.
|
||||||
services.caddy.virtualHosts = {
|
services.caddy.virtualHosts = {
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
dataDir = "/mnt/data/AppData/jellyfin";
|
dataDir = "/mnt/data/AppData/jellyfin";
|
||||||
cacheDir = "/mnt/data/AppData/jellyfin/cache";
|
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
|
# The upstream module hardcodes UMask=0077 — root cause of jellyfin writing
|
||||||
# trickplay thumbnails into stray new show folders it invented itself,
|
# trickplay thumbnails into stray new show folders it invented itself,
|
||||||
|
|||||||
Reference in New Issue
Block a user