Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and running a headless Jellyfin server over SSH: - Setup: install/uninstall Jellyfin, add media drives (single or btrfs RAID pool), import an existing detached btrfs pool non-destructively, firewall, move Jellyfin storage onto a media drive - Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical disk; never offers a disk hosting /, /boot or swap) - Keep-existing-filesystem path previews on-disk content, skips starter folders when content exists, and grants Jellyfin recursive read access - Manage: copy from external drives, mount/eject, rename/delete - Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART Single static binary; installs to /usr/local/bin so sudo can find it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
4.6 KiB
Go
170 lines
4.6 KiB
Go
// Package jellyfin detects what Jellyfin packages are installed on the host
|
|
// and whether the service is running. The Status type drives the Setup tab's
|
|
// "Install vs Reinstall" decision.
|
|
package jellyfin
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// WebPort is Jellyfin's default HTTP port. The firewall plan opens 8096/tcp.
|
|
const WebPort = 8096
|
|
|
|
// WebURLs returns the browseable addresses for the Jellyfin web UI on this
|
|
// host: http://<hostname>.local:<port> (mDNS) plus http://<lan-ip>:<port> for
|
|
// each real non-loopback IPv4. A headless server is reached by one of these
|
|
// from a browser on the same network (or over Tailscale). Virtual/bridge
|
|
// interfaces (docker, veth, bridges) are skipped — their IPs aren't reachable.
|
|
func WebURLs() []string {
|
|
var urls []string
|
|
if h, err := os.Hostname(); err == nil && h != "" {
|
|
host := h
|
|
if !strings.Contains(host, ".") {
|
|
host += ".local"
|
|
}
|
|
urls = append(urls, fmt.Sprintf("http://%s:%d", host, WebPort))
|
|
}
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return urls
|
|
}
|
|
for _, ifc := range ifaces {
|
|
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
n := ifc.Name
|
|
if strings.HasPrefix(n, "docker") || strings.HasPrefix(n, "br-") ||
|
|
strings.HasPrefix(n, "veth") || strings.HasPrefix(n, "virbr") {
|
|
continue
|
|
}
|
|
addrs, _ := ifc.Addrs()
|
|
for _, a := range addrs {
|
|
var ip net.IP
|
|
switch v := a.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
}
|
|
ip4 := ip.To4()
|
|
if ip4 == nil || ip4.IsLoopback() {
|
|
continue // skip IPv6 and loopback for the simple display
|
|
}
|
|
urls = append(urls, fmt.Sprintf("http://%s:%d", ip4.String(), WebPort))
|
|
}
|
|
}
|
|
return urls
|
|
}
|
|
|
|
// PackageSet is the union of all Jellyfin-related Arch packages we know about.
|
|
type PackageSet struct {
|
|
JellyfinBin bool // jellyfin-bin (AUR — official Microsoft-compiled binary)
|
|
Jellyfin bool // jellyfin (meta-package in official repos)
|
|
JellyfinServer bool // jellyfin-server (extra)
|
|
JellyfinWeb bool // jellyfin-web (extra)
|
|
JellyfinFFmpeg bool // jellyfin-ffmpeg
|
|
}
|
|
|
|
// Any reports whether ANY Jellyfin package is installed.
|
|
func (p PackageSet) Any() bool {
|
|
return p.JellyfinBin || p.Jellyfin || p.JellyfinServer || p.JellyfinWeb || p.JellyfinFFmpeg
|
|
}
|
|
|
|
// Installed returns the list of installed package names (in display order).
|
|
func (p PackageSet) Installed() []string {
|
|
var out []string
|
|
if p.JellyfinBin {
|
|
out = append(out, "jellyfin-bin")
|
|
}
|
|
if p.Jellyfin {
|
|
out = append(out, "jellyfin")
|
|
}
|
|
if p.JellyfinServer {
|
|
out = append(out, "jellyfin-server")
|
|
}
|
|
if p.JellyfinWeb {
|
|
out = append(out, "jellyfin-web")
|
|
}
|
|
if p.JellyfinFFmpeg {
|
|
out = append(out, "jellyfin-ffmpeg")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Status is a snapshot of how Jellyfin sits on this host.
|
|
type Status struct {
|
|
Packages PackageSet
|
|
ServiceUnit string // "jellyfin.service" if found, "" otherwise
|
|
ServiceActive bool // is jellyfin.service currently running?
|
|
UserExists bool // does the 'jellyfin' system user exist?
|
|
}
|
|
|
|
// LoadStatus inspects the host. Cheap to call repeatedly.
|
|
func LoadStatus() (Status, error) {
|
|
var s Status
|
|
s.Packages = detectPackages()
|
|
|
|
if unit, ok := detectServiceUnit(); ok {
|
|
s.ServiceUnit = unit
|
|
s.ServiceActive = isActive(unit)
|
|
}
|
|
|
|
if _, err := exec.Command("id", "-u", "jellyfin").Output(); err == nil {
|
|
s.UserExists = true
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// IsInstalled is shorthand — anywhere we just want a boolean to drive UI.
|
|
func (s Status) IsInstalled() bool {
|
|
return s.Packages.Any() || s.ServiceUnit != ""
|
|
}
|
|
|
|
// ---- internals ----
|
|
|
|
func detectPackages() PackageSet {
|
|
return PackageSet{
|
|
JellyfinBin: pacmanInstalled("jellyfin-bin"),
|
|
Jellyfin: pacmanInstalled("jellyfin"),
|
|
JellyfinServer: pacmanInstalled("jellyfin-server"),
|
|
JellyfinWeb: pacmanInstalled("jellyfin-web"),
|
|
JellyfinFFmpeg: pacmanInstalled("jellyfin-ffmpeg"),
|
|
}
|
|
}
|
|
|
|
func pacmanInstalled(name string) bool {
|
|
cmd := exec.Command("pacman", "-Qi", name)
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = nil
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
func detectServiceUnit() (string, bool) {
|
|
for _, unit := range []string{"jellyfin.service", "jellyfin-server.service"} {
|
|
out, err := exec.Command("systemctl", "list-unit-files", "--no-legend", unit).Output()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if strings.Contains(string(out), unit) {
|
|
return unit, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func isActive(unit string) bool {
|
|
cmd := exec.Command("systemctl", "is-active", "--quiet", unit)
|
|
if err := cmd.Run(); err != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) {
|
|
return false
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|