TUISTREAM/internal/jellyfin/jellyfin.go
28allday 959e507a83 Initial commit: TUISTREAM — headless Jellyfin TUI for Omarchy/Arch
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>
2026-05-31 16:47:18 +01:00

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
}