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 <noreply@anthropic.com>
This commit is contained in:
28allday 2026-07-18 13:58:22 +01:00
parent 77929de645
commit 96c908f324
4 changed files with 218 additions and 5 deletions

View file

@ -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, - **Setup** — install / uninstall Jellyfin, open or close the firewall ports,
copy the server's web address to your clipboard (works over SSH + tmux), and copy the server's web address to your clipboard (works over SSH + tmux), and
move Jellyfin's library storage onto a media drive. 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 - **Drive spin-down by default** — spinning media drives are automatically put
to sleep after 3 idle minutes (cooler, quieter) by a tiny background watcher 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 that survives reboots — and works even on NAS drives that ignore their own

View file

@ -5,6 +5,7 @@ import (
"strings" "strings"
"tuistream/internal/step" "tuistream/internal/step"
"tuistream/internal/system"
) )
// InstallPlan builds the ordered list of commands needed to install Jellyfin // 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 // This is a *plan*, not an execution. The TUI runs it step by step via
// tea.ExecProcess so pacman can prompt interactively if needed. // tea.ExecProcess so pacman can prompt interactively if needed.
func InstallPlan() []step.Step { func InstallPlan() []step.Step {
return []step.Step{ steps := []step.Step{
{ {
Title: "Refresh package databases", Title: "Refresh package databases",
Cmd: exec.Command("pacman", "-Sy", "--noconfirm"), Cmd: exec.Command("pacman", "-Sy", "--noconfirm"),
@ -24,11 +25,53 @@ func InstallPlan() []step.Step {
Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm", Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm",
"jellyfin-server", "jellyfin-web", "jellyfin-ffmpeg"), "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 // UninstallPlan reverses an install. Pass `purgeData` to also delete

74
internal/system/gpu.go Normal file
View file

@ -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)))
}

View file

@ -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())
}