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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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