From 96c908f32480c8e6bee1f0a658943c47841f94a3 Mon Sep 17 00:00:00 2001 From: 28allday Date: Sat, 18 Jul 2026 13:58:22 +0100 Subject: [PATCH] Auto-install GPU hardware-transcoding packages on Jellyfin install Detect the host's GPU vendor(s) via PCI sysfs (no lspci dependency) and append vendor-matched encoding-package steps to the install plan: Intel -> intel-media-driver + vpl-gpu-rt + intel-compute-runtime, AMD -> mesa + libva-mesa-driver. NVIDIA NVENC ships with the driver's own userspace, so nothing is added when a driver is loaded, and a warning step (never an automatic kernel-driver install) when it isn't. Without these packages Jellyfin installs fine and direct-plays fine, but any client that needs a transcode fails instantly with the opaque "FFmpeg exited with code 251" / "fatal playback error" pair. Co-Authored-By: Claude Fable 5 --- README.md | 6 +++ internal/jellyfin/install.go | 53 +++++++++++++++++++-- internal/system/gpu.go | 74 +++++++++++++++++++++++++++++ internal/system/gpu_test.go | 90 ++++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 internal/system/gpu.go create mode 100644 internal/system/gpu_test.go diff --git a/README.md b/README.md index 0c47cc8..e7550f6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ monitor, all without dropping you to a shell or switching terminals mid-task. - **Setup** — install / uninstall Jellyfin, open or close the firewall ports, copy the server's web address to your clipboard (works over SSH + tmux), and move Jellyfin's library storage onto a media drive. +- **Hardware transcoding ready out of the box** — the installer detects the + machine's GPU (Intel / AMD / NVIDIA) and installs the matching encoding + packages (Intel QSV/VA-API, AMD VA-API) so clients that need a transcode + don't hit "fatal playback error". NVIDIA NVENC needs only the driver you + already have; if no NVIDIA driver is loaded the installer says so rather + than guessing which kernel driver to install. - **Drive spin-down by default** — spinning media drives are automatically put to sleep after 3 idle minutes (cooler, quieter) by a tiny background watcher that survives reboots — and works even on NAS drives that ignore their own diff --git a/internal/jellyfin/install.go b/internal/jellyfin/install.go index e303c6d..52f1542 100644 --- a/internal/jellyfin/install.go +++ b/internal/jellyfin/install.go @@ -5,6 +5,7 @@ import ( "strings" "tuistream/internal/step" + "tuistream/internal/system" ) // InstallPlan builds the ordered list of commands needed to install Jellyfin @@ -14,7 +15,7 @@ import ( // This is a *plan*, not an execution. The TUI runs it step by step via // tea.ExecProcess so pacman can prompt interactively if needed. func InstallPlan() []step.Step { - return []step.Step{ + steps := []step.Step{ { Title: "Refresh package databases", Cmd: exec.Command("pacman", "-Sy", "--noconfirm"), @@ -24,11 +25,53 @@ func InstallPlan() []step.Step { Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm", "jellyfin-server", "jellyfin-web", "jellyfin-ffmpeg"), }, - { - Title: "Enable and start jellyfin.service", - Cmd: exec.Command("systemctl", "enable", "--now", "jellyfin.service"), - }, } + steps = append(steps, gpuEncodingSteps()...) + return append(steps, step.Step{ + Title: "Enable and start jellyfin.service", + Cmd: exec.Command("systemctl", "enable", "--now", "jellyfin.service"), + }) +} + +// gpuEncodingSteps maps the host's GPU vendor(s) to the userspace packages +// jellyfin-ffmpeg needs before hardware transcoding works on that vendor. +// Without them Jellyfin installs and direct-plays fine, but the moment a +// client needs a transcode every attempt dies at hw-init with the opaque +// "FFmpeg exited with code 251" / fatal-playback-error combo. +// +// NVIDIA is deliberately conservative: NVENC needs only the driver's own +// userspace (nvidia-utils), so with a loaded driver there is nothing to +// add, and without one we won't auto-install a kernel driver from here — +// picking nvidia vs nvidia-open vs -dkms per kernel flavour is a job for +// the distro/user, and getting it wrong can break the box's boot. +func gpuEncodingSteps() []step.Step { + var steps []step.Step + for _, v := range system.DetectGPUs() { + switch v { + case system.VendorIntel: + steps = append(steps, step.Step{ + Title: "Install Intel QSV/VA-API encoding packages", + Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm", + "intel-media-driver", "vpl-gpu-rt", "intel-compute-runtime"), + }) + case system.VendorAMD: + steps = append(steps, step.Step{ + Title: "Install AMD VA-API encoding packages", + Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm", + "mesa", "libva-mesa-driver"), + }) + case system.VendorNVIDIA: + if system.NvidiaDriverLoaded() { + continue + } + steps = append(steps, step.Step{ + Title: "NVIDIA GPU found but no driver loaded — skipping NVENC setup", + Cmd: exec.Command("bash", "-lc", + `echo "Install the NVIDIA driver for your kernel (nvidia / nvidia-open / nvidia-lts) and reboot to enable NVENC transcoding."`), + }) + } + } + return steps } // UninstallPlan reverses an install. Pass `purgeData` to also delete diff --git a/internal/system/gpu.go b/internal/system/gpu.go new file mode 100644 index 0000000..149531e --- /dev/null +++ b/internal/system/gpu.go @@ -0,0 +1,74 @@ +package system + +import ( + "os" + "path/filepath" + "strings" +) + +// GPU vendor detection for hardware-accelerated transcoding. We walk +// /sys/bus/pci/devices directly instead of shelling out to lspci so the +// pre-flight needs no extra package and works before any GPU driver is +// loaded (an unbound card still has its PCI class/vendor files). + +type GPUVendor string + +const ( + VendorIntel GPUVendor = "intel" + VendorAMD GPUVendor = "amd" + VendorNVIDIA GPUVendor = "nvidia" +) + +// DetectGPUs returns the distinct GPU vendors present on the host, in a +// stable intel/amd/nvidia order. Vendors jellyfin-ffmpeg has no encoder +// for are ignored. +func DetectGPUs() []GPUVendor { + return detectGPUs("/sys/bus/pci/devices") +} + +func detectGPUs(sysfsRoot string) []GPUVendor { + entries, err := os.ReadDir(sysfsRoot) + if err != nil { + return nil + } + seen := map[GPUVendor]bool{} + for _, e := range entries { + dir := filepath.Join(sysfsRoot, e.Name()) + // PCI class 0x03xxxx = display controller (VGA, 3D, other). + class := readSysfsHex(filepath.Join(dir, "class")) + if !strings.HasPrefix(class, "0x03") { + continue + } + switch readSysfsHex(filepath.Join(dir, "vendor")) { + case "0x8086": + seen[VendorIntel] = true + case "0x1002": + seen[VendorAMD] = true + case "0x10de": + seen[VendorNVIDIA] = true + } + } + var out []GPUVendor + for _, v := range []GPUVendor{VendorIntel, VendorAMD, VendorNVIDIA} { + if seen[v] { + out = append(out, v) + } + } + return out +} + +// NvidiaDriverLoaded reports whether the proprietary/open NVIDIA kernel +// driver is active. When it is, its userspace (nvidia-utils) is already +// installed as a dependency, which is all NVENC needs — no extra packages. +func NvidiaDriverLoaded() bool { + _, err := os.Stat("/proc/driver/nvidia") + return err == nil +} + +func readSysfsHex(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(string(b))) +} diff --git a/internal/system/gpu_test.go b/internal/system/gpu_test.go new file mode 100644 index 0000000..083806e --- /dev/null +++ b/internal/system/gpu_test.go @@ -0,0 +1,90 @@ +package system + +import ( + "os" + "path/filepath" + "testing" +) + +// fakePCIDev writes a minimal sysfs PCI device dir with class + vendor. +func fakePCIDev(t *testing.T, root, addr, class, vendor string) { + t.Helper() + dir := filepath.Join(root, addr) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for name, val := range map[string]string{"class": class, "vendor": vendor} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(val+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestDetectGPUs(t *testing.T) { + cases := []struct { + name string + devs [][3]string // addr, class, vendor + want []GPUVendor + }{ + { + name: "intel igpu only (zee)", + devs: [][3]string{ + {"0000:00:02.0", "0x030000", "0x8086"}, + {"0000:00:1f.3", "0x040300", "0x8086"}, // audio, same vendor — ignored + }, + want: []GPUVendor{VendorIntel}, + }, + { + name: "amd discrete", + devs: [][3]string{{"0000:03:00.0", "0x030000", "0x1002"}}, + want: []GPUVendor{VendorAMD}, + }, + { + name: "nvidia 3d controller class", + devs: [][3]string{{"0000:01:00.0", "0x030200", "0x10de"}}, + want: []GPUVendor{VendorNVIDIA}, + }, + { + name: "hybrid intel+nvidia, stable order", + devs: [][3]string{ + {"0000:01:00.0", "0x030000", "0x10de"}, + {"0000:00:02.0", "0x030000", "0x8086"}, + }, + want: []GPUVendor{VendorIntel, VendorNVIDIA}, + }, + { + name: "no display devices", + devs: [][3]string{{"0000:00:14.0", "0x0c0330", "0x8086"}}, + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, d := range tc.devs { + fakePCIDev(t, root, d[0], d[1], d[2]) + } + got := detectGPUs(root) + if len(got) != len(tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("got %v, want %v", got, tc.want) + } + } + }) + } +} + +func TestDetectGPUsMissingRoot(t *testing.T) { + if got := detectGPUs("/nonexistent/sysfs"); got != nil { + t.Fatalf("expected nil for missing root, got %v", got) + } +} + +// TestDetectGPUsRealHost never fails — it just logs what the real host +// reports so `go test -v` doubles as a quick manual check. +func TestDetectGPUsRealHost(t *testing.T) { + t.Logf("real host GPUs: %v (nvidia driver loaded: %v)", DetectGPUs(), NvidiaDriverLoaded()) +}