TUISTREAM/internal/tui/setup_actions.go
28allday 77929de645 Spin down idle media drives by default via a watcher service
Drives on a headless Jellyfin box ran hot 24/7: NAS-class drives (WD
Red, IronWolf) ship with no idle timer at all. v0.1.2 stopped tuistream
from keeping them awake; this makes them actually go to sleep.

Spin-down is a DEFAULT, not a feature: when the inventory shows
spinning media drives, the TUI silently installs/syncs the mechanism
and flashes what it did. [s] on Setup is the opt-OUT, remembered via
/etc/tuistream/spindown-off so the default never fights the user.

The mechanism is a tiny systemd service (tuistream --spindown-watch)
that samples /proc/diskstats and issues `hdparm -y` to any target drive
idle past 3 minutes. We deliberately do NOT use the drive's own standby
timer (hdparm -S): the 10TB helium WD Reds advertise it and then ignore
it — verified on real hardware (fresh -S 36, zero I/O, four minutes,
still active/idle). Forcing standby from outside works on everything.
An rc shipped the udev+hdparm -S approach; the plans clean its rule up.

Safety: targets are spinning (sysfs rotational), top-level, real disks,
never a system disk — same classifier the rest of Setup trusts. The
watcher's own probes can't disturb drives: /proc/diskstats and sysfs are
kernel memory, and hdparm -C (CHECK POWER MODE) doesn't wake or reset
anything. The unit's ExecStart points at the running binary and the
auto-sync rewrites it when the content goes stale (e.g. binary moved
from a dev path to /usr/local/bin).

hdparm joins the dependency pre-flight. Verified end-to-end on moviebox
(2x WD Red 10TB SATA + IronWolf 10TB USB): auto-applied on launch, all
three drives reached standby after 3 idle minutes, Monitor shows them
as "asleep" without waking them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:28:39 +01:00

887 lines
26 KiB
Go

package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/drives"
"tuistream/internal/firewall"
"tuistream/internal/jellyfin"
"tuistream/internal/spindown"
"tuistream/internal/step"
)
// setupStage is the current sub-state of the Setup tab. stageIdle is the
// default "inventory + key hints" view; other stages drive modal-like dialogs.
//
// The "running" and "done" states for an in-flight plan live on the root
// Model (see runStage / planRun) so both tabs can share the same step runner
// and progress rendering.
type setupStage int
const (
stageIdle setupStage = iota
stageConfirmInstall
stageConfirmUninstall
stageConfirmFirewall
stageConfirmSpindown
stageAddDrive
stageImportPool // import an existing detached btrfs pool (see importPoolState)
stageMoveJellyfinPick // choose which media drive to relocate onto (2+ managed)
stageConfirmMoveJellyfin // last-chance confirm before migrating
)
// planRun tracks an in-flight multi-step plan (install / uninstall / add
// drive / firewall / copy / delete / rename). Owned by the root Model so
// either tab can start one and have it rendered consistently.
type planRun struct {
title string
steps []step.Step
index int
err error
failedCmd string // string-form of the command that failed, for display
}
// runStage represents the lifecycle of the in-flight plan on the root model.
type runStage int
const (
runIdle runStage = iota
runRunning
runDone
)
// --- messages ---
type statusLoadedMsg struct {
status jellyfin.Status
err error
}
type stepFinishedMsg struct {
err error
cmd string // the command we tried to run (for error display)
}
type runCompleteMsg struct {
title string
err error
}
// loadStatusCmd asynchronously refreshes Jellyfin's installed/active state.
func loadStatusCmd() tea.Cmd {
return func() tea.Msg {
s, err := jellyfin.LoadStatus()
return statusLoadedMsg{status: s, err: err}
}
}
// runStepCmd executes a single step in the background without ever leaving
// the TUI. stdout + stderr are tee'd to /tmp/tuistream/last-step.log so the
// error view can surface the tail on failure; while the command runs the
// model is in stageRunning and shows a spinner + step counter.
func runStepCmd(s step.Step) tea.Cmd {
return func() tea.Msg {
logPath := sessionLogPath()
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
// Truncate at the start of every step so the tail shown on error
// only contains output from THIS step.
logf, err := os.Create(logPath)
if err == nil {
s.Cmd.Stdout = logf
s.Cmd.Stderr = logf
defer logf.Close()
}
runErr := s.Cmd.Run()
return stepFinishedMsg{err: runErr, cmd: s.Cmd.String()}
}
}
// sessionLogPath chooses a path the operator can easily read without sudo:
// /tmp/tuistream/last-step.log on Linux (world-readable, survives until reboot),
// falling back to the user's cache if /tmp isn't usable. We deliberately don't
// use ~/.cache when running as root, because the operator who launched
// `sudo tuistream` then can't easily read /root/.cache.
func sessionLogPath() string {
const tmpDir = "/tmp/tuistream"
if err := os.MkdirAll(tmpDir, 0o1777); err == nil {
_ = os.Chmod(tmpDir, 0o1777)
return filepath.Join(tmpDir, "last-step.log")
}
if h, err := os.UserHomeDir(); err == nil {
return filepath.Join(h, ".cache", "tuistream", "last-step.log")
}
return "/tmp/tuistream-last-step.log"
}
// tail returns up to the last n lines of the session log, or an empty string.
func tailSessionLog(n int) string {
b, err := os.ReadFile(sessionLogPath())
if err != nil {
return ""
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
// `script` records a "Script started" / "Script done" preamble — drop them.
cleaned := lines[:0]
for _, l := range lines {
if strings.HasPrefix(l, "Script started") || strings.HasPrefix(l, "Script done") {
continue
}
cleaned = append(cleaned, l)
}
return strings.Join(cleaned, "\n")
}
// --- key handling on the Setup tab ---
// handleSetupKey routes a key press to the appropriate sub-stage handler.
// Returns the new model and any command. The bool result tells the caller
// whether the key was consumed (true) or should fall through to the root
// model's defaults (false).
//
// We pass the raw tea.KeyMsg through so sub-stages that contain a
// bubbles/textinput (like Add-drive's name entry) can feed it directly into
// the component; the simpler stages just look at msg.String().
func handleSetupKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
key := msg.String()
switch m.setup.stage {
case stageIdle:
return setupIdleKey(m, key)
case stageConfirmInstall:
return setupConfirmInstallKey(m, key)
case stageConfirmUninstall:
return setupConfirmUninstallKey(m, key)
case stageConfirmFirewall:
return setupConfirmFirewallKey(m, key)
case stageConfirmSpindown:
return setupConfirmSpindownKey(m, key)
case stageAddDrive:
return setupAddDriveKey(m, msg)
case stageImportPool:
return setupImportPoolKey(m, msg)
case stageMoveJellyfinPick:
return setupMoveJellyfinPickKey(m, key)
case stageConfirmMoveJellyfin:
return setupMoveJellyfinConfirmKey(m, key)
}
return m, nil, false
}
func setupIdleKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "d":
// Toggle between the friendly drive list and the raw lsblk table.
m.setup.showTech = !m.setup.showTech
return m, nil, true
case "i":
// Install (or reinstall) — confirm before running.
m.setup.stage = stageConfirmInstall
m.setup.confirmIdx = 0 // default "Yes"
return m, nil, true
case "u":
if m.status.IsInstalled() {
m.setup.stage = stageConfirmUninstall
m.setup.confirmIdx = 1 // default "No"
return m, nil, true
}
m.flash = "Jellyfin isn't installed — nothing to uninstall."
return m, nil, true
case "a":
if m.inventory == nil {
m.flash = "Inventory still loading — press 'r' once it's done."
return m, nil, true
}
cands := m.inventory.Candidates()
m.setup.addDrive = newAddDriveState(cands)
m.setup.stage = stageAddDrive
return m, nil, true
case "p":
if m.inventory == nil {
m.flash = "Inventory still loading — press 'r' once it's done."
return m, nil, true
}
return startImportPool(m)
case "f":
m.setup.firewallTarget = !m.firewall.AllOpen()
m.setup.stage = stageConfirmFirewall
m.setup.confirmIdx = 0
return m, nil, true
case "s":
if m.inventory == nil {
m.flash = "Inventory still loading — press 'r' once it's done."
return m, nil, true
}
targets := spindownTargets(m.inventory)
if len(targets) == 0 {
m.flash = "No spinning media drives detected — nothing to spin down."
return m, nil, true
}
m.setup.spindownDrives = targets
m.setup.spindownTarget = !spindown.Enabled()
m.setup.stage = stageConfirmSpindown
m.setup.confirmIdx = 0
return m, nil, true
case "j":
return setupStartMoveJellyfin(m)
case "y", "Y":
// Copy the Jellyfin web address to the user's local clipboard (OSC 52),
// the only thing that works from a headless box over SSH + tmux.
if !m.status.ServiceActive {
m.flash = "Start Jellyfin first — no web address to copy yet."
return m, nil, true
}
urls := jellyfin.WebURLs()
if len(urls) == 0 {
m.flash = "Couldn't determine a web address to copy."
return m, nil, true
}
m.flash = "Copied " + urls[0] + " to clipboard"
return m, copyToClipboard(urls[0]), true
}
return m, nil, false
}
func setupConfirmFirewallKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.setup.confirmIdx = 0
return m, nil, true
case "right", "l":
m.setup.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.setup.confirmIdx = 0
return runFirewall(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if m.setup.confirmIdx == 0 {
return runFirewall(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runFirewall(m Model) (Model, tea.Cmd, bool) {
var plan []step.Step
title := ""
if m.setup.firewallTarget {
plan = firewall.OpenPlan()
title = "Opening Jellyfin firewall ports (8096/tcp, 7359/udp)"
} else {
plan = firewall.ClosePlan()
title = "Closing Jellyfin firewall ports (8096/tcp, 7359/udp)"
}
if len(plan) == 0 {
m.flash = "Nothing to do (firewall already in target state?)"
m.setup.stage = stageIdle
return m, nil, true
}
m.setup.stage = stageIdle
m.run = planRun{title: title, steps: plan, index: 0}
m.runStage = runRunning
m.runOwnerTab = tabSetup
return m, runStepCmd(plan[0]), true
}
func setupConfirmSpindownKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.setup.confirmIdx = 0
return m, nil, true
case "right", "l":
m.setup.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.setup.confirmIdx = 0
return runSpindown(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if m.setup.confirmIdx == 0 {
return runSpindown(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runSpindown(m Model) (Model, tea.Cmd, bool) {
var plan []step.Step
title := ""
if m.setup.spindownTarget {
plan = spindown.EnablePlan(m.setup.spindownDrives)
title = fmt.Sprintf("Enabling drive spin-down (%d min idle)", spindown.TimeoutMinutes)
} else {
plan = spindown.DisablePlan(m.setup.spindownDrives)
title = "Disabling drive spin-down"
}
m.setup.stage = stageIdle
m.run = planRun{title: title, steps: plan, index: 0}
m.runStage = runRunning
m.runOwnerTab = tabSetup
return m, runStepCmd(plan[0]), true
}
// spindownTargets picks the disks the spin-down watcher may touch. The
// selection logic lives in the spindown package (the watcher daemon uses
// the same rules); this is just the nil-tolerant TUI entry point.
func spindownTargets(inv *drives.Inventory) []spindown.Target {
return spindown.Targets(inv)
}
// setupStartMoveJellyfin gates entry into the move-storage flow and routes to
// either the picker (2+ media drives) or straight to confirm (exactly one).
func setupStartMoveJellyfin(m Model) (Model, tea.Cmd, bool) {
if !m.status.IsInstalled() {
m.flash = "Install Jellyfin first (press i)."
return m, nil, true
}
if jellyfin.LoadStorageState().Moved {
m.flash = "Jellyfin storage is already on a media drive."
return m, nil, true
}
if m.inventory == nil {
m.flash = "Drives still loading — press r."
return m, nil, true
}
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "Add a media drive first (press a)."
return m, nil, true
}
m.setup.moveChoices = managed
m.setup.movePickIdx = 0
m.setup.confirmIdx = 0
if len(managed) == 1 {
m.setup.moveTarget = managed[0]
m.setup.stage = stageConfirmMoveJellyfin
return m, nil, true
}
m.setup.stage = stageMoveJellyfinPick
return m, nil, true
}
func setupMoveJellyfinPickKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup
switch key {
case "up", "k":
if st.movePickIdx > 0 {
st.movePickIdx--
}
return m, nil, true
case "down", "j":
if st.movePickIdx < len(st.moveChoices)-1 {
st.movePickIdx++
}
return m, nil, true
case "esc":
st.stage = stageIdle
return m, nil, true
case "enter":
st.moveTarget = st.moveChoices[st.movePickIdx]
st.confirmIdx = 0
st.stage = stageConfirmMoveJellyfin
return m, nil, true
}
return m, nil, false
}
func setupMoveJellyfinConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.setup.confirmIdx = 0
return m, nil, true
case "right", "l":
m.setup.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.setup.confirmIdx = 0
return runMoveJellyfin(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if m.setup.confirmIdx == 0 {
return runMoveJellyfin(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runMoveJellyfin(m Model) (Model, tea.Cmd, bool) {
plan := jellyfin.MovePlan(jellyfin.MoveOptions{
MountPoint: m.setup.moveTarget.MountPoint,
ServiceUnit: m.status.ServiceUnit,
})
m.setup.stage = stageIdle
m.run = planRun{
title: "Moving Jellyfin storage to " + m.setup.moveTarget.MountPoint,
steps: plan,
index: 0,
}
m.runStage = runRunning
m.runOwnerTab = tabSetup
return m, runStepCmd(plan[0]), true
}
func setupConfirmInstallKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.setup.confirmIdx = 0
return m, nil, true
case "right", "l":
m.setup.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.setup.confirmIdx = 0
return runInstall(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if m.setup.confirmIdx == 0 {
return runInstall(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runInstall(m Model) (Model, tea.Cmd, bool) {
plan := jellyfin.InstallPlan()
m.setup.stage = stageIdle
m.run = planRun{
title: "Installing Jellyfin",
steps: plan,
index: 0,
}
m.runStage = runRunning
m.runOwnerTab = tabSetup
return m, runStepCmd(plan[0]), true
}
func setupConfirmUninstallKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.setup.confirmIdx = 0
return m, nil, true
case "right", "l":
m.setup.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.setup.confirmIdx = 0
return runUninstall(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if m.setup.confirmIdx == 0 {
return runUninstall(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runUninstall(m Model) (Model, tea.Cmd, bool) {
plan := jellyfin.UninstallPlan(m.status.Packages, false /* don't purge data by default */)
if len(plan) == 0 {
m.flash = "Nothing to uninstall."
m.setup.stage = stageIdle
return m, nil, true
}
m.setup.stage = stageIdle
m.run = planRun{
title: "Uninstalling Jellyfin",
steps: plan,
index: 0,
}
m.runStage = runRunning
m.runOwnerTab = tabSetup
return m, runStepCmd(plan[0]), true
}
// --- step-finished routing ---
// advanceRun handles a stepFinishedMsg by either firing the next step or
// finishing the run. Returns the updated model + the next command.
func advanceRun(m Model, msg stepFinishedMsg) (Model, tea.Cmd) {
if msg.err != nil {
m.run.err = msg.err
m.run.failedCmd = msg.cmd
m.runStage = runDone
return m, nil
}
m.run.index++
if m.run.index >= len(m.run.steps) {
m.runStage = runDone
return m, tea.Batch(loadStatusCmd(), loadFirewallCmd(), loadInventoryCmd(m.username))
}
next := m.run.steps[m.run.index]
return m, runStepCmd(next)
}
// dismissRun resets the in-flight plan and returns the owning tab to its
// idle state. Called when the user presses any key on the runDone splash.
func dismissRun(m Model) (Model, tea.Cmd) {
m.run = planRun{}
m.runStage = runIdle
switch m.runOwnerTab {
case tabSetup:
m.setup.stage = stageIdle
case tabManage:
m.manage.stage = manageIdle
m.manage.active = manageAction{}
}
return m, tea.Batch(loadStatusCmd(), loadInventoryCmd(m.username))
}
// --- rendering for the stages ---
// renderSetupActionBar shows the keyboard shortcuts at the bottom of the
// idle setup view. The set of keys depends on current install + firewall state.
func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDetachedPool, spindownEligible, spindownOn bool, width int) string {
var keys []string
if installed {
keys = append(keys, "[i] reinstall")
keys = append(keys, "[u] uninstall")
} else {
keys = append(keys, "[i] install Jellyfin")
}
keys = append(keys, "[a] add drive")
if hasDetachedPool {
keys = append(keys, "[p] import pool")
}
if fwOpen {
keys = append(keys, "[f] close firewall")
} else {
keys = append(keys, "[f] open firewall")
}
if spindownEligible {
if spindownOn {
keys = append(keys, "[s] turn off spin-down")
} else {
keys = append(keys, "[s] re-enable spin-down")
}
}
if installed {
if jellyfinMoved {
keys = append(keys, headerStyle.Render("[j] storage on media ✓"))
} else {
keys = append(keys, "[j] move Jellyfin storage")
}
}
if serviceActive {
keys = append(keys, "[y] copy URL")
}
return wrapKeyBar(keys, width)
}
// renderFirewallLine shows the UFW status in the Setup tab header.
func renderFirewallLine(s firewall.State) string {
if !s.UFWInstalled {
return labelStyle.Render("Firewall:") + " " +
headerStyle.Render("UFW not installed (no firewall management)")
}
switch {
case s.AllOpen():
return labelStyle.Render("Firewall:") + " " +
roleAvailableStyle.Render("8096/tcp + 7359/udp open")
case s.AnyOpen():
mix := []string{}
if s.WebOpen {
mix = append(mix, roleAvailableStyle.Render("8096/tcp open"))
} else {
mix = append(mix, roleSystemStyle.Render("8096/tcp closed"))
}
if s.DiscoveryOpen {
mix = append(mix, roleAvailableStyle.Render("7359/udp open"))
} else {
mix = append(mix, roleSystemStyle.Render("7359/udp closed"))
}
return labelStyle.Render("Firewall:") + " " + strings.Join(mix, " · ")
default:
return labelStyle.Render("Firewall:") + " " +
roleSystemStyle.Render("closed (LAN devices can't reach Jellyfin)")
}
}
// renderConfirmFirewall shows the open/close confirmation modal.
func renderConfirmFirewall(s firewall.State, opening bool, idx int) string {
var rows []string
verb := "Open"
desc := "Allow other devices on your LAN to reach Jellyfin's web UI and find this server in their apps."
if !opening {
verb = "Close"
desc = "Block LAN traffic to Jellyfin. Localhost access still works, but the apps on your phone / TV won't be able to reach this server."
}
rows = append(rows, titleStyle.Render(verb+" Jellyfin firewall ports?"))
rows = append(rows, "")
rows = append(rows, desc)
rows = append(rows, "")
rows = append(rows, "Ports:")
rows = append(rows, " · "+devNameStyle.Render(firewall.WebPort)+" (HTTP web UI / API)")
rows = append(rows, " · "+devNameStyle.Render(firewall.DiscoveryPort)+" (Jellyfin client auto-discovery)")
if !s.UFWInstalled {
rows = append(rows, "")
rows = append(rows, roleSystemStyle.Render("UFW isn't installed — nothing to do."))
}
rows = append(rows, "")
yes := " Yes "
no := " Cancel "
if idx == 0 {
yes = roleAvailableStyle.Render("▸" + yes)
no = " " + no
} else {
yes = " " + yes
no = roleSystemStyle.Render("▸" + no)
}
rows = append(rows, yes+" "+no)
rows = append(rows, "")
rows = append(rows, footerStyle.Render("←/→ move · enter confirm · y / n shortcut · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// renderSpindownLine shows the idle-timer status in the Setup tab header.
// Only rendered when at least one spinning media drive exists.
func renderSpindownLine() string {
if spindown.Enabled() {
return labelStyle.Render("Spin-down:") + " " +
roleAvailableStyle.Render(fmt.Sprintf("drives sleep after %d min idle", spindown.TimeoutMinutes))
}
if spindown.OptedOut() {
return labelStyle.Render("Spin-down:") + " " +
headerStyle.Render("off by your choice — drives run 24/7 ([s] re-enables)")
}
return labelStyle.Render("Spin-down:") + " " +
headerStyle.Render("off — applying default shortly…")
}
// renderConfirmSpindown shows the enable/disable confirmation modal with
// the exact drives the timer will touch.
func renderConfirmSpindown(targets []spindown.Target, enabling bool, idx int) string {
var rows []string
verb := "Re-enable"
desc := fmt.Sprintf(
"Spin these drives down after %d minutes of inactivity (the TUISTREAM default). They run cooler and quieter; the first play after a sleep takes a few seconds while they wake.",
spindown.TimeoutMinutes)
if !enabling {
verb = "Turn off"
desc = "Remove the idle timer — drives return to their factory behaviour (NAS drives spin 24/7). TUISTREAM remembers this and won't re-apply the default."
}
rows = append(rows, titleStyle.Render(verb+" drive spin-down?"))
rows = append(rows, "")
rows = append(rows, desc)
rows = append(rows, "")
rows = append(rows, "Drives:")
for _, t := range targets {
name := t.Model
if name == "" {
name = t.Name
}
rows = append(rows, " · "+devNameStyle.Render(name)+" ("+t.Device+")")
}
if enabling {
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"A small background service watches for idle drives — works across reboots,"))
rows = append(rows, headerStyle.Render(
"even on drives that ignore their own firmware timer. System drives are never touched."))
}
rows = append(rows, "")
yes := " Yes "
no := " Cancel "
if idx == 0 {
yes = roleAvailableStyle.Render("▸" + yes)
no = " " + no
} else {
yes = " " + yes
no = roleSystemStyle.Render("▸" + no)
}
rows = append(rows, yes+" "+no)
rows = append(rows, "")
rows = append(rows, footerStyle.Render("←/→ move · enter confirm · y / n shortcut · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// renderStatusLine shows "Jellyfin: not installed" or "Jellyfin: installed
// (jellyfin-bin) · service active" at the top of the Setup tab.
func renderStatusLine(s jellyfin.Status) string {
if !s.IsInstalled() {
return labelStyle.Render("Jellyfin:") + " " +
roleSystemStyle.Render("not installed")
}
bits := []string{
labelStyle.Render("Jellyfin:"),
roleAvailableStyle.Render("installed"),
}
if s.ServiceActive {
bits = append(bits, roleAvailableStyle.Render("· service active"))
} else if s.ServiceUnit != "" {
bits = append(bits, roleInUseStyle.Render("· service stopped"))
}
line := strings.Join(bits, " ")
// Once the service is up, show where to point a browser — the whole point
// of a headless server. Listed for hostname.local + each LAN/Tailscale IP.
if s.ServiceActive {
if urls := jellyfin.WebURLs(); len(urls) > 0 {
styled := make([]string, len(urls))
for i, u := range urls {
styled[i] = devNameStyle.Render(u)
}
line += "\n" + labelStyle.Render("Browse:") + " " +
strings.Join(styled, headerStyle.Render(" · "))
}
}
return line
}
func sprintList(s []string) string {
switch len(s) {
case 0:
return ""
case 1:
return s[0]
}
return strings.Join(s, ", ")
}
func renderConfirmInstall(idx int) string {
var rows []string
rows = append(rows, titleStyle.Render("Install Jellyfin?"))
rows = append(rows, "")
rows = append(rows, "This will install from the official Arch extra repo:")
rows = append(rows, " · "+roleAvailableStyle.Render("jellyfin-server"))
rows = append(rows, " · "+roleAvailableStyle.Render("jellyfin-web"))
rows = append(rows, " · "+roleAvailableStyle.Render("jellyfin-ffmpeg"))
rows = append(rows, "")
rows = append(rows, "Then enable & start "+devNameStyle.Render("jellyfin.service")+".")
rows = append(rows, "")
yes := " Yes, install "
no := " Cancel "
if idx == 0 {
yes = roleAvailableStyle.Render("▸" + yes)
no = " " + no
} else {
yes = " " + yes
no = roleSystemStyle.Render("▸" + no)
}
rows = append(rows, yes+" "+no)
rows = append(rows, "")
rows = append(rows, footerStyle.Render("←/→ move · enter confirm · y / n shortcut · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderConfirmUninstall(s jellyfin.Status, idx int) string {
packages := s.Packages.Installed()
var rows []string
rows = append(rows, titleStyle.Render("Uninstall Jellyfin?"))
rows = append(rows, "")
rows = append(rows, "This will:")
rows = append(rows, " · Stop and disable jellyfin.service")
rows = append(rows, " · Remove "+roleSystemStyle.Render(sprintList(packages)))
rows = append(rows, "")
rows = append(rows, "Your library DB and settings in /var/lib/jellyfin will be KEPT.")
rows = append(rows, "")
yes := " Yes, uninstall "
no := " Cancel "
if idx == 0 {
yes = roleSystemStyle.Render("▸" + yes)
no = " " + no
} else {
yes = " " + yes
no = roleAvailableStyle.Render("▸" + no)
}
rows = append(rows, yes+" "+no)
rows = append(rows, "")
rows = append(rows, footerStyle.Render("←/→ move · enter confirm · y / n shortcut · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// renderRunning is the in-flight progress view: title, step counter, a
// spinner against the current step's name, and the completed steps so far.
// No terminal-switching, no flicker — the TUI stays put while pacman /
// mkfs / mount / setfacl / ufw run in the background.
func renderRunning(sp spinner.Model, run planRun) string {
var rows []string
rows = append(rows, titleStyle.Render(run.title))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(fmt.Sprintf(
"Step %d of %d", run.index+1, len(run.steps))))
rows = append(rows, "")
for i, s := range run.steps {
switch {
case i < run.index:
rows = append(rows, " "+roleAvailableStyle.Render("✓")+" "+s.Title)
case i == run.index:
rows = append(rows, " "+sp.View()+" "+roleAvailableStyle.Render(s.Title))
default:
rows = append(rows, " "+headerStyle.Render("· "+s.Title))
}
}
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"Captured output is being written to "+sessionLogPath()+
" — tail it from another shell if you want a live view."))
return centeredCard(strings.Join(rows, "\n"))
}
func renderRunDone(run planRun) string {
var rows []string
if run.err != nil {
rows = append(rows, roleSystemStyle.Render("Something went wrong."))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(run.title))
rows = append(rows, "")
failedStep := ""
if run.index < len(run.steps) {
failedStep = run.steps[run.index].Title
}
if failedStep != "" {
rows = append(rows, "Failed step: "+failedStep)
}
if run.failedCmd != "" {
rows = append(rows, "Command: "+run.failedCmd)
}
rows = append(rows, "Error: "+run.err.Error())
rows = append(rows, "")
if tail := tailSessionLog(20); tail != "" {
rows = append(rows, headerStyle.Render("Last output (from "+sessionLogPath()+"):"))
rows = append(rows, frameStyle.Render(tail))
} else {
rows = append(rows, headerStyle.Render(
"No captured output. Re-run the command manually to see what happened:"))
if run.failedCmd != "" {
rows = append(rows, " "+run.failedCmd)
}
}
} else {
rows = append(rows, roleAvailableStyle.Render("✓ "+run.title+" — complete."))
rows = append(rows, "")
for _, s := range run.steps {
rows = append(rows, " ✓ "+s.Title)
}
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("Press any key to return."))
return centeredCard(strings.Join(rows, "\n"))
}