Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c908f324 |
4 changed files with 218 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
74
internal/system/gpu.go
Normal 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)))
|
||||||
|
}
|
||||||
90
internal/system/gpu_test.go
Normal file
90
internal/system/gpu_test.go
Normal 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())
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue