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>
This commit is contained in:
parent
262ba8e3b4
commit
77929de645
7 changed files with 508 additions and 9 deletions
13
README.md
13
README.md
|
|
@ -14,6 +14,11 @@ 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.
|
||||
- **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
|
||||
firmware idle timer (looking at you, WD Red). System drives are never
|
||||
touched; `s` opts out if you want 24/7 spinning.
|
||||
- **Add media drive** — attach a spare disk or partition: keep its existing
|
||||
filesystem or format it (btrfs / ext4 / xfs), or combine 2+ disks into a
|
||||
**btrfs RAID pool** (1 / 0 / 5 / 10). The boot drive is never offered — the
|
||||
|
|
@ -90,6 +95,14 @@ A first-run, from an empty box to a working server — all from the **Setup** ta
|
|||
Optional: press `j` to move Jellyfin's own library database and metadata off the
|
||||
OS drive onto a media drive (handy on a small boot SSD).
|
||||
|
||||
Note on **drive spin-down**: when TUISTREAM sees spinning media drives it
|
||||
automatically installs a small watcher service that spins them down after 3
|
||||
idle minutes, so they don't run hot 24/7. (It watches actual disk I/O rather
|
||||
than trusting the drive's own idle timer, which many NAS drives silently
|
||||
ignore.) The first play after a sleep takes a few seconds while the drives
|
||||
wake. Press `s` to opt out — TUISTREAM remembers and won't re-apply the
|
||||
default.
|
||||
|
||||
## Build from source
|
||||
|
||||
Needs Go 1.26+.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"tuistream/internal/spindown"
|
||||
"tuistream/internal/system"
|
||||
"tuistream/internal/theme"
|
||||
"tuistream/internal/tui"
|
||||
|
|
@ -32,6 +33,8 @@ func main() {
|
|||
readOnly := flag.Bool("read-only", false,
|
||||
"open the TUI without checking for root; only the inventory views work")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
spindownWatch := flag.Bool("spindown-watch", false,
|
||||
"internal: run the drive idle watcher (started by tuistream-spindown.service)")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
|
|
@ -39,6 +42,19 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
// Daemon mode: no TUI, just the idle watcher (root needed for hdparm).
|
||||
if *spindownWatch {
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "tuistream --spindown-watch must run as root")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := spindown.Watch(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tuistream:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !*readOnly && os.Geteuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
"tuistream needs administrator rights to install Jellyfin, edit /etc/fstab, etc.")
|
||||
|
|
|
|||
276
internal/spindown/spindown.go
Normal file
276
internal/spindown/spindown.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
// Package spindown puts spinning media drives to sleep after a few idle
|
||||
// minutes. NAS-class drives (WD Red, IronWolf) ship with NO idle timer at
|
||||
// all — they spin 24/7 and run warm even when nothing touches them.
|
||||
//
|
||||
// Spin-down is a DEFAULT, not a feature the user enables: whenever the
|
||||
// inventory shows spinning media drives, the TUI silently syncs the
|
||||
// watcher onto the box (see AutoSyncDue). The Setup tab's [s] key is the
|
||||
// opt-OUT — disabling writes a marker file so the default never fights
|
||||
// the user.
|
||||
//
|
||||
// Mechanism: a tiny systemd service runs `tuistream --spindown-watch`,
|
||||
// which samples /proc/diskstats and issues `hdparm -y` to any target
|
||||
// drive that's been idle past the timeout. We deliberately do NOT use the
|
||||
// drive's own standby timer (`hdparm -S`): common NAS drives — the 10TB
|
||||
// helium WD Reds included — advertise the timer and then ignore it.
|
||||
// Forcing standby from the outside works on everything `hdparm -y`
|
||||
// works on, which we can verify per-drive.
|
||||
//
|
||||
// The watcher's own probes never touch the platters: /proc/diskstats and
|
||||
// sysfs are kernel memory, and `hdparm -C` is an ATA CHECK POWER MODE,
|
||||
// which neither wakes a sleeping drive nor resets its idle state.
|
||||
//
|
||||
// System disks are never targeted: Targets() refuses anything the OS
|
||||
// lives on, using the same classifier the rest of Setup trusts.
|
||||
package spindown
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tuistream/internal/drives"
|
||||
"tuistream/internal/step"
|
||||
)
|
||||
|
||||
// UnitPath is the systemd service that runs the watcher.
|
||||
const UnitPath = "/etc/systemd/system/tuistream-spindown.service"
|
||||
|
||||
const unitName = "tuistream-spindown.service"
|
||||
|
||||
// legacyRulesPath is the v0.2.0-rc udev/`hdparm -S` approach, removed on
|
||||
// sync: the firmware timer it relied on is ignored by common NAS drives.
|
||||
const legacyRulesPath = "/etc/udev/rules.d/69-tuistream-spindown.rules"
|
||||
|
||||
// OptOutPath marks "the user turned spin-down off on purpose" — its
|
||||
// presence stops the auto-sync from re-enabling the default behind their
|
||||
// back. Written by DisablePlan, removed by EnablePlan.
|
||||
const OptOutPath = "/etc/tuistream/spindown-off"
|
||||
|
||||
// TimeoutMinutes is the idle time before a drive is spun down.
|
||||
const TimeoutMinutes = 3
|
||||
|
||||
// WatchInterval is how often the watcher samples /proc/diskstats.
|
||||
const WatchInterval = 30 * time.Second
|
||||
|
||||
// Target is one spinning, non-system physical disk the watcher may touch.
|
||||
type Target struct {
|
||||
Device string // /dev/sda
|
||||
Name string // sda
|
||||
Model string
|
||||
}
|
||||
|
||||
// Targets picks the disks the watcher may touch: spinning (per sysfs),
|
||||
// top-level, real hardware, and never a disk the OS lives on.
|
||||
func Targets(inv *drives.Inventory) []Target {
|
||||
if inv == nil {
|
||||
return nil
|
||||
}
|
||||
var ts []Target
|
||||
for _, d := range inv.All {
|
||||
if d.Type != "disk" || drives.IsPseudoDisk(d.Name) {
|
||||
continue
|
||||
}
|
||||
if inv.SystemDisks[d.Name] {
|
||||
continue
|
||||
}
|
||||
if !rotational(d.Name) {
|
||||
continue
|
||||
}
|
||||
ts = append(ts, Target{Device: d.Path, Name: d.Name, Model: d.Model})
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// rotational reports whether a disk spins, per sysfs. Anything we can't
|
||||
// read is treated as non-rotational so SSDs/NVMe are never targeted by a
|
||||
// misread.
|
||||
func rotational(name string) bool {
|
||||
b, err := os.ReadFile("/sys/block/" + name + "/queue/rotational")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(b)) == "1"
|
||||
}
|
||||
|
||||
// Enabled reports whether the watcher service is installed.
|
||||
func Enabled() bool {
|
||||
_, err := os.Stat(UnitPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// OptedOut reports whether the user explicitly disabled the default.
|
||||
func OptedOut() bool {
|
||||
_, err := os.Stat(OptOutPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// AutoSyncDue reports whether the silent default-apply should run: there
|
||||
// are spinning media drives, the user hasn't opted out, and the unit on
|
||||
// disk doesn't match what we'd write (missing, stale, or pointing at a
|
||||
// binary that has since moved — e.g. after a proper install).
|
||||
func AutoSyncDue(ts []Target) bool {
|
||||
if len(ts) == 0 || OptedOut() {
|
||||
return false
|
||||
}
|
||||
b, err := os.ReadFile(UnitPath)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return string(b) != unitContent()
|
||||
}
|
||||
|
||||
// unitContent renders the service. ExecStart points at the running
|
||||
// binary so a dev copy works too; when the binary later moves (proper
|
||||
// install), the content no longer matches and AutoSyncDue triggers a
|
||||
// rewrite.
|
||||
func unitContent() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
exe = "/usr/local/bin/tuistream"
|
||||
}
|
||||
return `[Unit]
|
||||
Description=TUISTREAM drive spin-down watcher
|
||||
Documentation=https://github.com/28allday/TUISTREAM
|
||||
|
||||
[Service]
|
||||
ExecStart=` + exe + ` --spindown-watch
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
}
|
||||
|
||||
// EnablePlan installs and starts the watcher, clears any opt-out marker,
|
||||
// and removes the legacy udev rule from the rc builds.
|
||||
func EnablePlan(ts []Target) []step.Step {
|
||||
return []step.Step{
|
||||
{
|
||||
Title: "Install spin-down watcher service",
|
||||
Cmd: exec.Command("bash", "-c",
|
||||
"cat > "+UnitPath+" <<'TUISTREAM_EOF'\n"+unitContent()+"TUISTREAM_EOF"),
|
||||
},
|
||||
{Title: "Clear spin-down opt-out", Cmd: exec.Command("rm", "-f", OptOutPath)},
|
||||
{Title: "Remove legacy udev rule", Cmd: exec.Command("rm", "-f", legacyRulesPath)},
|
||||
{Title: "Reload systemd units", Cmd: exec.Command("systemctl", "daemon-reload")},
|
||||
{Title: "Start spin-down watcher", Cmd: exec.Command("systemctl", "enable", "--now", unitName)},
|
||||
}
|
||||
}
|
||||
|
||||
// DisablePlan stops and removes the watcher and records the opt-out so
|
||||
// the default never re-applies itself. Drives return to their factory
|
||||
// behaviour (NAS drives spin 24/7).
|
||||
func DisablePlan(ts []Target) []step.Step {
|
||||
return []step.Step{
|
||||
{
|
||||
Title: "Stop spin-down watcher",
|
||||
Cmd: exec.Command("bash", "-c", "systemctl disable --now "+unitName+" 2>/dev/null; true"),
|
||||
},
|
||||
{Title: "Remove watcher service", Cmd: exec.Command("rm", "-f", UnitPath)},
|
||||
{Title: "Remove legacy udev rule", Cmd: exec.Command("rm", "-f", legacyRulesPath)},
|
||||
{Title: "Reload systemd units", Cmd: exec.Command("systemctl", "daemon-reload")},
|
||||
{
|
||||
Title: "Record spin-down opt-out",
|
||||
Cmd: exec.Command("bash", "-c",
|
||||
"mkdir -p "+filepath.Dir(OptOutPath)+" && touch "+OptOutPath),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AutoSync is the silent path for the default: same work as EnablePlan
|
||||
// but run directly (no step-runner UI), used when the TUI notices the
|
||||
// watcher is missing or stale. Returns the first error; the TUI surfaces
|
||||
// it as a flash rather than a failure screen.
|
||||
func AutoSync(ts []Target) error {
|
||||
for _, s := range EnablePlan(ts) {
|
||||
if err := s.Cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s: %w", s.Title, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- the watcher itself (`tuistream --spindown-watch`) ----------
|
||||
|
||||
// Watch is the daemon loop. Every WatchInterval it re-derives the target
|
||||
// set (so hotplugged drives are covered without a restart), reads each
|
||||
// target's I/O counters, and spins down any drive that has been idle past
|
||||
// the timeout and is still spinning. Logs go to stdout → journald.
|
||||
func Watch() error {
|
||||
timeout := time.Duration(TimeoutMinutes) * time.Minute
|
||||
log.Printf("watching for drives idle ≥ %s (sampling every %s)", timeout, WatchInterval)
|
||||
|
||||
type diskState struct {
|
||||
sig string // last-seen I/O counter signature
|
||||
last time.Time // when the signature last changed
|
||||
}
|
||||
states := map[string]*diskState{}
|
||||
|
||||
for {
|
||||
inv, err := drives.Load("")
|
||||
if err != nil {
|
||||
log.Printf("inventory failed (will retry): %v", err)
|
||||
time.Sleep(WatchInterval)
|
||||
continue
|
||||
}
|
||||
for _, t := range Targets(inv) {
|
||||
sig := ioSignature(t.Name)
|
||||
if sig == "" {
|
||||
continue
|
||||
}
|
||||
st := states[t.Name]
|
||||
if st == nil || st.sig != sig {
|
||||
states[t.Name] = &diskState{sig: sig, last: time.Now()}
|
||||
continue
|
||||
}
|
||||
if time.Since(st.last) < timeout || !isSpinning(t.Device) {
|
||||
continue
|
||||
}
|
||||
if err := exec.Command("hdparm", "-y", t.Device).Run(); err != nil {
|
||||
log.Printf("couldn't spin down %s: %v", t.Device, err)
|
||||
// Push last forward so a refusing drive is retried after a
|
||||
// full timeout instead of every sample.
|
||||
st.last = time.Now()
|
||||
continue
|
||||
}
|
||||
log.Printf("spun down %s (%s) after %s idle", t.Device, t.Model, timeout)
|
||||
}
|
||||
time.Sleep(WatchInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// ioSignature condenses a disk's /proc/diskstats counters that only move
|
||||
// on real I/O: reads/writes completed and sectors read/written. Fields
|
||||
// like io_ticks and in_flight churn on their own and are excluded.
|
||||
func ioSignature(name string) string {
|
||||
b, err := os.ReadFile("/proc/diskstats")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 10 || f[2] != name {
|
||||
continue
|
||||
}
|
||||
return f[3] + " " + f[5] + " " + f[7] + " " + f[9]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isSpinning reports whether the drive is in active/idle (as opposed to
|
||||
// standby/sleeping). `hdparm -C` issues CHECK POWER MODE, which doesn't
|
||||
// wake a sleeping drive. On error we report false — never send a sleep
|
||||
// command to a drive we can't read.
|
||||
func isSpinning(device string) bool {
|
||||
out, err := exec.Command("hdparm", "-C", device).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "active")
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ var Required = []Dep{
|
|||
{Pkg: "gptfdisk", Binary: "sgdisk"}, // Setup > wipe-whole-disk
|
||||
{Pkg: "parted", Binary: "partprobe"}, // Setup > partprobe after wipe
|
||||
{Pkg: "smartmontools", Binary: "smartctl"}, // Monitor > SMART health per disk
|
||||
{Pkg: "hdparm", Binary: "hdparm"}, // Setup > drive spin-down timer
|
||||
}
|
||||
|
||||
// Missing returns the subset of Required whose binary isn't on $PATH.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"tuistream/internal/drives"
|
||||
"tuistream/internal/firewall"
|
||||
"tuistream/internal/jellyfin"
|
||||
"tuistream/internal/spindown"
|
||||
"tuistream/internal/theme"
|
||||
)
|
||||
|
||||
|
|
@ -68,6 +69,10 @@ type Model struct {
|
|||
runStage runStage
|
||||
runOwnerTab tab
|
||||
|
||||
// spindownSyncing guards the silent spin-down default apply so a
|
||||
// refresh mid-sync can't start a second one.
|
||||
spindownSyncing bool
|
||||
|
||||
// spinner ticked while a step is running in the background.
|
||||
spinner spinner.Model
|
||||
|
||||
|
|
@ -109,6 +114,26 @@ func (m Model) Init() tea.Cmd {
|
|||
)
|
||||
}
|
||||
|
||||
type spindownSyncedMsg struct{ err error }
|
||||
|
||||
// spindownAutoSyncCmd applies the spin-down default in the background when
|
||||
// it's due (spinning media drives present, no opt-out, rule missing or
|
||||
// stale). Returns nil — no work, no UI churn — in the common case where
|
||||
// the rule already matches.
|
||||
func (m *Model) spindownAutoSyncCmd() tea.Cmd {
|
||||
if m.spindownSyncing || os.Geteuid() != 0 {
|
||||
return nil
|
||||
}
|
||||
targets := spindownTargets(m.inventory)
|
||||
if !spindown.AutoSyncDue(targets) {
|
||||
return nil
|
||||
}
|
||||
m.spindownSyncing = true
|
||||
return func() tea.Msg {
|
||||
return spindownSyncedMsg{err: spindown.AutoSync(targets)}
|
||||
}
|
||||
}
|
||||
|
||||
// healthRefreshCmd builds the next health probe, including SMART only when
|
||||
// its slower cadence is due. Marks lastSmart at issue time so an in-flight
|
||||
// probe isn't doubled up by the next tick.
|
||||
|
|
@ -161,6 +186,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case inventoryLoadedMsg:
|
||||
m.inventory = msg.inv
|
||||
m.inventoryErr = msg.err
|
||||
// Spin-down is a default, not a feature: whenever the fresh
|
||||
// inventory shows spinning media drives that the udev rule doesn't
|
||||
// cover yet (first run, or a drive was added), silently sync it —
|
||||
// unless the user opted out via [s]. Root only; --read-only can't
|
||||
// write rules.
|
||||
return m, m.spindownAutoSyncCmd()
|
||||
|
||||
case spindownSyncedMsg:
|
||||
m.spindownSyncing = false
|
||||
if msg.err != nil {
|
||||
m.flash = "Couldn't apply drive spin-down: " + msg.err.Error()
|
||||
} else {
|
||||
m.flash = fmt.Sprintf(
|
||||
"Drive spin-down active — media drives sleep after %d min idle ([s] turns it off)",
|
||||
spindown.TimeoutMinutes)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case statusLoadedMsg:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ const (
|
|||
stageConfirmInstall
|
||||
stageConfirmUninstall
|
||||
stageConfirmFirewall
|
||||
stageConfirmSpindown
|
||||
stageAddDrive
|
||||
stageImportPool // import an existing detached btrfs pool (see importPoolState)
|
||||
stageMoveJellyfinPick // choose which media drive to relocate onto (2+ managed)
|
||||
|
|
@ -158,6 +161,8 @@ func handleSetupKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
|
|||
return setupConfirmUninstallKey(m, key)
|
||||
case stageConfirmFirewall:
|
||||
return setupConfirmFirewallKey(m, key)
|
||||
case stageConfirmSpindown:
|
||||
return setupConfirmSpindownKey(m, key)
|
||||
case stageAddDrive:
|
||||
return setupAddDriveKey(m, msg)
|
||||
case stageImportPool:
|
||||
|
|
@ -209,6 +214,21 @@ func setupIdleKey(m Model, key string) (Model, tea.Cmd, bool) {
|
|||
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":
|
||||
|
|
@ -275,6 +295,54 @@ func runFirewall(m Model) (Model, tea.Cmd, bool) {
|
|||
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) {
|
||||
|
|
@ -490,7 +558,7 @@ func dismissRun(m Model) (Model, tea.Cmd) {
|
|||
|
||||
// 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 bool, width int) string {
|
||||
func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDetachedPool, spindownEligible, spindownOn bool, width int) string {
|
||||
var keys []string
|
||||
if installed {
|
||||
keys = append(keys, "[i] reinstall")
|
||||
|
|
@ -507,6 +575,13 @@ func renderSetupActionBar(installed, fwOpen, serviceActive, jellyfinMoved, hasDe
|
|||
} 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 ✓"))
|
||||
|
|
@ -585,6 +660,68 @@ func renderConfirmFirewall(s firewall.State, opening bool, idx int) string {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"tuistream/internal/drives"
|
||||
"tuistream/internal/jellyfin"
|
||||
"tuistream/internal/spindown"
|
||||
)
|
||||
|
||||
// setupModel holds Setup-tab-specific state: the current sub-stage (idle,
|
||||
|
|
@ -17,6 +18,13 @@ type setupModel struct {
|
|||
stage setupStage
|
||||
confirmIdx int // 0 = Yes, 1 = No (used by confirm modals)
|
||||
firewallTarget bool // true = "we're about to open", false = "we're about to close"
|
||||
|
||||
// Drive spin-down flow: true = about to enable, false = about to
|
||||
// disable; spindownDrives is the resolved target list shown on the
|
||||
// confirm screen and fed to the plan.
|
||||
spindownTarget bool
|
||||
spindownDrives []spindown.Target
|
||||
|
||||
addDrive addDriveState // state for the multi-stage Add-drive flow
|
||||
importPool importPoolState // state for the import-existing-pool flow
|
||||
showTech bool // 'd' toggle: false = friendly drive list, true = raw lsblk table
|
||||
|
|
@ -47,6 +55,8 @@ func (s setupModel) view(m Model) string {
|
|||
return renderConfirmUninstall(m.status, s.confirmIdx)
|
||||
case stageConfirmFirewall:
|
||||
return renderConfirmFirewall(m.firewall, m.setup.firewallTarget, s.confirmIdx)
|
||||
case stageConfirmSpindown:
|
||||
return renderConfirmSpindown(m.setup.spindownDrives, m.setup.spindownTarget, s.confirmIdx)
|
||||
case stageAddDrive:
|
||||
return renderAddDrive(m)
|
||||
case stageImportPool:
|
||||
|
|
@ -61,7 +71,8 @@ func (s setupModel) view(m Model) string {
|
|||
fw := renderFirewallLine(m.firewall)
|
||||
jellyfinMoved := jellyfin.LoadStorageState().Moved
|
||||
hasDetachedPool := len(m.inventory.DetachedPools()) > 0
|
||||
actions := renderSetupActionBar(m.status.IsInstalled(), m.firewall.AllOpen(), m.status.ServiceActive, jellyfinMoved, hasDetachedPool, cardWidth(m.width))
|
||||
spindownEligible := len(spindownTargets(m.inventory)) > 0
|
||||
actions := renderSetupActionBar(m.status.IsInstalled(), m.firewall.AllOpen(), m.status.ServiceActive, jellyfinMoved, hasDetachedPool, spindownEligible, spindown.Enabled(), cardWidth(m.width))
|
||||
|
||||
var heading, subhead, inv string
|
||||
if s.showTech {
|
||||
|
|
@ -77,13 +88,17 @@ func (s setupModel) view(m Model) string {
|
|||
hint := headerStyle.Render(techToggleHint(s.showTech))
|
||||
|
||||
w := cardWidth(m.width)
|
||||
headerLines := []string{centered(status, w), centered(fw, w)}
|
||||
if spindownEligible {
|
||||
headerLines = append(headerLines, centered(renderSpindownLine(), w))
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Center,
|
||||
"",
|
||||
centered(heading, w),
|
||||
centered(subhead, w),
|
||||
"",
|
||||
centered(status, w),
|
||||
centered(fw, w),
|
||||
strings.Join(headerLines, "\n"),
|
||||
"",
|
||||
inv,
|
||||
centered(hint, w),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue