TUISTREAM/internal/tui/add_drive.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

897 lines
29 KiB
Go

package tui
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/drives"
"tuistream/internal/step"
)
// Sub-stages of the Add-drive flow. Each lives inside stageAddDrive at the
// setupModel level, switched via addDrive.subStage.
type addDriveSubStage int
const (
addPick addDriveSubStage = iota // multi-select drives (1 → single mode, 2+ → RAID)
addFormat // single-drive mode: keep existing FS or pick mkfs target
addRaidLevel // multi-drive mode: pick RAID level (1, 0, 5, 10)
addName // text-input the friendly directory label
addConfirm // last-chance Yes/No before mounting
)
// addDriveState carries the Add-drive sub-flow's state across Update calls.
type addDriveState struct {
subStage addDriveSubStage
candidates []drives.Drive
pickIdx int // cursor row in the picker
pickSelected []bool // parallel to candidates: which rows are ticked
selected drives.Drive // single-drive mode: the chosen drive
pool []drives.Drive // multi-drive mode: the chosen drives
formatOpts []formatOption
formatIdx int
formatAs drives.FormatChoice
raidLevels []drives.RAIDLevel
raidIdx int
raidLevel drives.RAIDLevel
wipeWholeDisks bool // RAID confirm: zap entire parent disks (default true)
wipeWholeDisk bool // single-drive confirm: zap the partition's parent disk (default false)
name textinput.Model
confirmIdx int // 0 = Yes, 1 = No
inspection string // human-readable summary of existing files (Keep path only)
hasContent bool // true when the kept filesystem already holds real data
}
// isPool returns true when the Add-drive run is in multi-drive (RAID) mode.
func (s *addDriveState) isPool() bool { return len(s.pool) >= 2 }
// formatOption is one row in the addFormat sub-stage's menu.
type formatOption struct {
choice drives.FormatChoice
label string
hint string
}
// newAddDriveState seeds the textinput with a sensible default label and
// returns the initial state for the Add-drive flow.
func newAddDriveState(cands []drives.Drive) addDriveState {
ti := textinput.New()
ti.Prompt = ""
ti.Placeholder = "MediaDrive"
ti.CharLimit = 32
ti.Width = 32
ti.Focus()
return addDriveState{
subStage: addPick,
candidates: cands,
pickSelected: make([]bool, len(cands)),
name: ti,
}
}
// defaultLabelFor picks an initial label for the textinput: the drive's
// filesystem label if it exists and is clean, else "MediaDrive".
func defaultLabelFor(d drives.Drive) string {
if d.Label != "" {
// Sanitise: replace spaces with hyphens, drop anything weird.
cleaned := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_':
return r
case r == ' ':
return '-'
}
return -1
}, d.Label)
if cleaned != "" {
return cleaned
}
}
return "MediaDrive"
}
// --- key handling ---
func setupAddDriveKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
switch st.subStage {
case addPick:
return addDrivePickKey(m, msg.String())
case addFormat:
return addDriveFormatKey(m, msg.String())
case addRaidLevel:
return addDriveRaidLevelKey(m, msg.String())
case addName:
return addDriveNameKey(m, msg)
case addConfirm:
return addDriveConfirmKey(m, msg.String())
}
return m, nil, false
}
func addDrivePickKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
switch key {
case "up", "k":
if st.pickIdx > 0 {
st.pickIdx--
}
return m, nil, true
case "down", "j":
if st.pickIdx < len(st.candidates)-1 {
st.pickIdx++
}
return m, nil, true
case " ", "space":
// Toggle selection on the cursor row. When toggling ON, auto-deselect
// any peer that would conflict: selecting a whole disk drops any of
// its child partitions, and vice versa.
if len(st.pickSelected) > st.pickIdx {
newState := !st.pickSelected[st.pickIdx]
st.pickSelected[st.pickIdx] = newState
if newState {
current := st.candidates[st.pickIdx]
for i, d := range st.candidates {
if i == st.pickIdx || !st.pickSelected[i] {
continue
}
// Conflict: current is a partition whose parent is `d`.
if d.Type == "disk" && current.ParentDisk == d.Name {
st.pickSelected[i] = false
}
// Conflict: current is a disk and `d` is one of its partitions.
if current.Type == "disk" && d.ParentDisk == current.Name {
st.pickSelected[i] = false
}
}
}
}
return m, nil, true
case "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if len(st.candidates) == 0 {
m.flash = "No eligible drives — see the inventory for why."
m.setup.stage = stageIdle
return m, nil, true
}
// Collect the toggled drives. If nothing's toggled, treat the cursor
// row as a one-drive single-mode pick.
var chosen []drives.Drive
for i, ok := range st.pickSelected {
if ok {
chosen = append(chosen, st.candidates[i])
}
}
if len(chosen) == 0 {
chosen = []drives.Drive{st.candidates[st.pickIdx]}
}
// If multiple selections collapse onto a single parent disk, the
// user is really asking to use that whole disk — RAID on a disk
// against itself isn't a thing, and would fail at mkfs.btrfs AFTER
// we've already wiped it. Auto-collapse to whole-disk single-drive
// mode instead.
if len(chosen) >= 2 {
parents := drives.UniqueParents(chosen)
if len(parents) == 1 && m.inventory != nil {
if wholeDisk := m.inventory.FindDisk(parents[0]); wholeDisk != nil {
st.selected = *wholeDisk
st.pool = nil
st.formatOpts = buildFormatOptions(*wholeDisk) // no "keep" — disk has no FS
st.formatIdx = 0
st.formatAs = st.formatOpts[0].choice
st.wipeWholeDisk = true // forced — wiping is implicit for whole disks
st.subStage = addFormat
m.flash = fmt.Sprintf(
"All %d picks were on %s — switched to whole-disk mode.",
len(chosen), wholeDisk.Path)
return m, nil, true
}
}
}
if len(chosen) == 1 {
// Single-drive mode — existing flow.
st.selected = chosen[0]
st.pool = nil
st.formatOpts = buildFormatOptions(st.selected)
st.formatIdx = 0
st.formatAs = st.formatOpts[0].choice
st.subStage = addFormat
return m, nil, true
}
// Multi-drive mode — go to RAID level picker. Default wipe to ON;
// it's the right answer for RAID and the confirm screen makes the
// consequences fully visible before the user signs off.
st.pool = chosen
st.selected = drives.Drive{}
st.raidLevels = drives.AvailableLevels(len(chosen))
st.raidIdx = 0
st.raidLevel = st.raidLevels[0]
st.wipeWholeDisks = true
st.subStage = addRaidLevel
return m, nil, true
}
return m, nil, false
}
func addDriveRaidLevelKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
switch key {
case "up", "k":
if st.raidIdx > 0 {
st.raidIdx--
}
return m, nil, true
case "down", "j":
if st.raidIdx < len(st.raidLevels)-1 {
st.raidIdx++
}
return m, nil, true
case "esc":
st.subStage = addPick
return m, nil, true
case "enter":
st.raidLevel = st.raidLevels[st.raidIdx]
st.subStage = addName
// Default label for a pool: "MediaPool"
st.name.SetValue("MediaPool")
st.name.CursorEnd()
return m, nil, true
}
return m, nil, false
}
// buildFormatOptions returns the format menu for `d`. If the drive already
// has a supported filesystem we offer "Keep existing" first; if it's empty
// or has an unsupported FS we go straight to mkfs choices.
func buildFormatOptions(d drives.Drive) []formatOption {
var opts []formatOption
hasUsableFS := false
switch d.FSType {
case "ext4", "btrfs", "xfs", "exfat", "ntfs", "vfat":
hasUsableFS = true
}
if hasUsableFS {
opts = append(opts, formatOption{
choice: drives.FormatKeep,
label: fmt.Sprintf("Keep existing %s filesystem and files", d.FSType),
hint: "No data is touched. Recommended unless you want a fresh start.",
})
}
opts = append(opts,
formatOption{
choice: drives.FormatBtrfs,
label: "Format as btrfs",
hint: "Modern default. Snapshots, scrubs, easy to grow into a multi-disk pool later.",
},
formatOption{
choice: drives.FormatExt4,
label: "Format as ext4",
hint: "Most compatible. Pick if you read this drive on Linux only and want zero surprises.",
},
formatOption{
choice: drives.FormatXFS,
label: "Format as xfs",
hint: "Great with very large media files and lots of metadata.",
},
)
return opts
}
func addDriveFormatKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
switch key {
case "up", "k":
if st.formatIdx > 0 {
st.formatIdx--
}
return m, nil, true
case "down", "j":
if st.formatIdx < len(st.formatOpts)-1 {
st.formatIdx++
}
return m, nil, true
case "esc":
st.subStage = addPick
return m, nil, true
case "enter":
st.formatAs = st.formatOpts[st.formatIdx].choice
st.subStage = addName
st.name.SetValue(defaultLabelFor(st.selected))
st.name.CursorEnd()
return m, nil, true
}
return m, nil, false
}
func addDriveNameKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
keyStr := msg.String()
switch keyStr {
case "esc":
st.subStage = addPick
return m, nil, true
case "enter":
if !validLabel(st.name.Value()) {
m.flash = "Use only letters, digits, '-' and '_' in the label."
return m, nil, true
}
// When we're keeping an existing single-drive filesystem, preview what's
// on it (via a temporary read-only mount) so the confirm screen can
// reassure the user their data is safe — and so we can decide whether to
// seed starter folders. Skipped for formats (about to be erased anyway)
// and pools (always freshly mkfs'd).
st.inspection = ""
st.hasContent = false
if !st.isPool() && st.formatAs == drives.FormatKeep && st.selected.FSType != "" {
st.inspection, st.hasContent = drives.InspectDevice(st.selected.Path)
}
st.subStage = addConfirm
st.confirmIdx = 0
return m, nil, true
case "tab", "shift+tab":
// Let the root model switch tabs.
return m, nil, false
}
var cmd tea.Cmd
st.name, cmd = st.name.Update(msg)
return m, cmd, true
}
func addDriveConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
switch key {
case "left", "h":
st.confirmIdx = 0
return m, nil, true
case "right", "l":
st.confirmIdx = 1
return m, nil, true
case "w":
// Toggle the wipe-whole-disk option in both modes — but only
// meaningful for single-drive when we're actually going to format.
if st.isPool() {
st.wipeWholeDisks = !st.wipeWholeDisks
} else if st.formatAs != drives.FormatKeep {
st.wipeWholeDisk = !st.wipeWholeDisk
}
return m, nil, true
case "y", "Y":
st.confirmIdx = 0
return runAddDrive(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if st.confirmIdx == 0 {
return runAddDrive(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runAddDrive(m Model) (Model, tea.Cmd, bool) {
st := &m.setup.addDrive
var (
plan []step.Step
err error
title string
)
if st.isPool() {
plan, err = drives.PoolPlan(drives.PoolOptions{
Drives: st.pool,
Level: st.raidLevel,
Label: st.name.Value(),
User: m.username,
WipeWholeDisks: st.wipeWholeDisks,
})
title = fmt.Sprintf("Creating btrfs %s pool at /media/%s/%s",
st.raidLevel, m.username, st.name.Value())
} else {
plan, err = drives.MountPlan(drives.MountOptions{
Drive: st.selected,
Label: st.name.Value(),
User: m.username,
FormatAs: st.formatAs,
WipeWholeDisk: st.wipeWholeDisk && st.formatAs != drives.FormatKeep,
// Seed library folders unless we're keeping a drive that already
// holds the user's own content — don't clutter their layout.
SeedStarterFolders: !(st.formatAs == drives.FormatKeep && st.hasContent),
// When keeping a drive that already has media, grant jellyfin read
// over the WHOLE existing tree — a non-recursive grant would leave
// pre-existing files unreadable if they were copied with tight perms.
RecursiveACL: st.formatAs == drives.FormatKeep && st.hasContent,
})
title = fmt.Sprintf("Adding media drive at /media/%s/%s",
m.username, st.name.Value())
}
if err != nil {
m.flash = "Couldn't plan the mount: " + err.Error()
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
}
// validLabel: alnum + dash + underscore, 1..32 chars.
func validLabel(s string) bool {
if len(s) == 0 || len(s) > 32 {
return false
}
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// --- rendering ---
func renderAddDrive(m Model) string {
st := &m.setup.addDrive
switch st.subStage {
case addPick:
return renderAddDrivePick(st)
case addFormat:
return renderAddDriveFormat(st)
case addRaidLevel:
return renderAddDriveRaidLevel(st)
case addName:
return renderAddDriveName(m.username, st)
case addConfirm:
if st.isPool() {
return renderAddDriveConfirmPool(m.username, st, m.inventory)
}
return renderAddDriveConfirm(m.username, st, m.inventory)
}
return ""
}
func renderAddDriveConfirmPool(user string, st *addDriveState, inv *drives.Inventory) string {
mp := filepath.Join("/media", user, st.name.Value())
var rows []string
rows = append(rows, titleStyle.Render(fmt.Sprintf("Create btrfs %s pool?", st.raidLevel)))
rows = append(rows, "")
rows = append(rows, "Selected partitions:")
for _, d := range st.pool {
rows = append(rows, " · "+devNameStyle.Render(d.Path)+" ("+d.Size+", "+
dashIfEmpty(d.FSType)+", "+dashIfEmpty(d.Label)+")")
}
rows = append(rows, "")
// Wipe-scope toggle. This is the critical bit: explain exactly what
// each choice means in concrete terms so the user can't accidentally
// destroy data they didn't expect to.
parents := drives.UniqueParents(st.pool)
scope := "Use only the selected partitions"
scopeBody := []string{
headerStyle.Render(
"Only the selected partitions are wiped. Other partitions on the same disks are untouched."),
roleSystemStyle.Render(
" ⚠ Risk: stray RAID/LVM/FS signatures on the unused parts of the parent disks can"),
roleSystemStyle.Render(
" confuse btrfs about pool membership at next boot."),
}
if st.wipeWholeDisks {
scope = "Wipe ENTIRE parent disks (recommended for RAID)"
scopeBody = []string{
headerStyle.Render(
"Every byte of these whole disks is erased before mkfs.btrfs:"),
}
for _, p := range parents {
dev := "/dev/" + p
// Build the full set of partitions on this parent and split
// them into "picked by user" and "collateral wipe".
pickedSet := map[string]bool{}
for _, d := range st.pool {
if d.ParentDisk == p {
pickedSet[d.Path] = true
}
}
var picked, collateral []string
if inv != nil {
for _, c := range inv.ChildrenOf(p) {
desc := fmt.Sprintf("%s (%s, %s%s)",
c.Path, c.Size, dashIfEmpty(c.FSType),
labelSuffix(c.Label))
if pickedSet[c.Path] {
picked = append(picked, desc)
} else {
collateral = append(collateral, desc)
}
}
}
scopeBody = append(scopeBody,
roleSystemStyle.Render(" "+dev+" → ERASE")+
headerStyle.Render(" contains:"))
for _, line := range picked {
scopeBody = append(scopeBody, headerStyle.Render(" · "+line+" (picked)"))
}
for _, line := range collateral {
scopeBody = append(scopeBody, roleSystemStyle.Render(" · "+line+" ⚠ also erased"))
}
}
scopeBody = append(scopeBody,
headerStyle.Render("mkfs.btrfs will use the whole disks ("+
strings.Join(prefixDevSlice(parents), ", ")+") directly."))
}
mark := "[ ]"
if st.wipeWholeDisks {
mark = roleSystemStyle.Render("[✓]")
}
rows = append(rows, "Wipe scope: "+mark+" "+scope+" "+headerStyle.Render("(w to toggle)"))
for _, l := range scopeBody {
rows = append(rows, " "+l)
}
rows = append(rows, "")
rows = append(rows, "RAID level: "+roleAvailableStyle.Render(string(st.raidLevel))+
" ("+st.raidLevel.Tolerates()+")")
rows = append(rows, "mkfs: "+
fmt.Sprintf("mkfs.btrfs -d %s -m %s", st.raidLevel, st.raidLevel.MetadataProfile()))
rows = append(rows, "Will appear: "+devNameStyle.Render(mp))
rows = append(rows, "ACL grant: jellyfin user gets read access")
rows = append(rows, "fstab: UUID-based, mounts the pool via the first device")
rows = append(rows, "Starter dirs: "+roleAvailableStyle.Render("[✓]")+" "+
fmt.Sprintf("%d folders created under JellyfinMedia/", len(drives.StarterFolders)))
rows = append(rows, " "+headerStyle.Render(strings.Join(drives.StarterFolders, ", ")))
rows = append(rows, "")
yes := " Yes, create pool "
no := " Cancel "
if st.confirmIdx == 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 · w wipe scope · enter confirm · y/n shortcut · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
func labelSuffix(l string) string {
if l == "" {
return ""
}
return ", label " + strconv.Quote(l)
}
func prefixDevSlice(names []string) []string {
out := make([]string, len(names))
for i, n := range names {
out[i] = "/dev/" + n
}
return out
}
func renderAddDriveFormat(st *addDriveState) string {
var rows []string
rows = append(rows, titleStyle.Render("Filesystem"))
rows = append(rows, "")
rows = append(rows, fmt.Sprintf("Drive: %s (%s)",
devNameStyle.Render(st.selected.Path), st.selected.Size))
state := dashIfEmpty(st.selected.FSType)
if st.selected.Label != "" {
state = fmt.Sprintf("%s, label %q", state, st.selected.Label)
}
rows = append(rows, "Current state: "+state)
rows = append(rows, "")
for i, opt := range st.formatOpts {
marker := " "
title := opt.label
body := headerStyle.Render(opt.hint)
if i == st.formatIdx {
marker = roleAvailableStyle.Render("▸ ")
title = roleAvailableStyle.Render(opt.label)
}
rows = append(rows, marker+title)
rows = append(rows, " "+body)
rows = append(rows, "")
}
if st.formatOpts[st.formatIdx].choice != drives.FormatKeep {
rows = append(rows, roleSystemStyle.Render(
"⚠ Formatting ERASES every file on this drive."))
rows = append(rows, "")
}
rows = append(rows, footerStyle.Render("↑/↓ move · enter select · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderAddDrivePick(st *addDriveState) string {
var rows []string
rows = append(rows, titleStyle.Render("Add media drive(s)"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"Pick ONE drive for a single mount, or TWO+ for a btrfs RAID pool."))
rows = append(rows, headerStyle.Render(
"Space toggles a row. Enter advances. With nothing toggled, enter picks the cursor row."))
rows = append(rows, "")
if len(st.candidates) == 0 {
rows = append(rows, roleSystemStyle.Render("No eligible drives detected."))
rows = append(rows,
"Plug in a drive that isn't part of the boot disk and isn't already mounted.")
rows = append(rows, "")
rows = append(rows, footerStyle.Render("esc to cancel · r to refresh"))
return centeredCard(strings.Join(rows, "\n"))
}
rows = append(rows, headerStyle.Render(fmt.Sprintf(
" %-18s %-7s %-12s %-14s %s",
"Device", "Size", "Filesystem", "Label", "Model / kind",
)))
count := 0
for i, d := range st.candidates {
box := "[ ]"
if st.pickSelected[i] {
box = roleAvailableStyle.Render("[✓]")
count++
}
fsCol := dashIfEmpty(d.FSType)
labelCol := dashIfEmpty(d.Label)
modelCol := d.Model
if d.Type == "disk" {
fsCol = "—"
labelCol = "—"
extra := "WHOLE DISK"
if d.Model != "" {
extra = "WHOLE DISK · " + d.Model
}
if d.Transport != "" {
extra += " (" + d.Transport + ")"
}
modelCol = extra
}
row := fmt.Sprintf(
" %s %-18s %-7s %-12s %-14s %s",
box, d.Path, d.Size,
truncate(fsCol, 12),
truncate(labelCol, 14),
modelCol,
)
if i == st.pickIdx {
row = roleAvailableStyle.Render("▸") + row[1:]
}
rows = append(rows, row)
}
rows = append(rows, "")
switch count {
case 0:
rows = append(rows, headerStyle.Render("0 selected — enter will pick the cursor row (single-drive mode)."))
case 1:
rows = append(rows, "1 selected — "+roleAvailableStyle.Render("single-drive mode"))
default:
rows = append(rows, fmt.Sprintf("%d selected — ", count)+
roleAvailableStyle.Render("RAID pool mode"))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("↑/↓ move · space toggle · enter continue · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderAddDriveRaidLevel(st *addDriveState) string {
var rows []string
rows = append(rows, titleStyle.Render("Pool configuration"))
rows = append(rows, "")
rows = append(rows, fmt.Sprintf("%d drives selected:", len(st.pool)))
for _, d := range st.pool {
rows = append(rows, " · "+devNameStyle.Render(d.Path)+" ("+d.Size+", "+dashIfEmpty(d.FSType)+")")
}
rows = append(rows, "")
rows = append(rows, roleSystemStyle.Render(
"⚠ All selected drives will be ERASED and combined into a single btrfs filesystem."))
rows = append(rows, "")
for i, lvl := range st.raidLevels {
marker := " "
head := raidLevelTitle(lvl)
hint := raidLevelHint(lvl)
caveat := raidLevelCaveat(lvl)
if i == st.raidIdx {
marker = roleAvailableStyle.Render("▸ ")
head = roleAvailableStyle.Render(head)
}
rows = append(rows, marker+head)
rows = append(rows, " "+headerStyle.Render(hint))
if caveat != "" {
rows = append(rows, " "+roleSystemStyle.Render(caveat))
}
rows = append(rows, "")
}
rows = append(rows, footerStyle.Render("↑/↓ move · enter select · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
func raidLevelTitle(l drives.RAIDLevel) string {
switch l {
case drives.RAID0:
return "RAID 0 (stripe)"
case drives.RAID1:
return "RAID 1 (mirror)"
case drives.RAID5:
return "RAID 5 (single parity)"
case drives.RAID10:
return "RAID 10 (mirror + stripe)"
}
return string(l)
}
func raidLevelHint(l drives.RAIDLevel) string {
switch l {
case drives.RAID0:
return "Maximum capacity, no redundancy. One drive failing loses the whole pool."
case drives.RAID1:
return "Half the capacity, every byte exists twice. Any single drive can fail. Safest default."
case drives.RAID5:
return "One drive's worth of parity. Tolerates a single drive failure. Better capacity than RAID 1."
case drives.RAID10:
return "Mirrors striped together. One drive in each mirror can fail. Great for big libraries."
}
return ""
}
func raidLevelCaveat(l drives.RAIDLevel) string {
if l == drives.RAID5 {
return "Caveat: btrfs RAID 5/6 has a known write-hole issue on unclean shutdowns; ensure your box is on UPS."
}
return ""
}
func renderAddDriveName(user string, st *addDriveState) string {
var rows []string
rows = append(rows, titleStyle.Render("Name this drive"))
rows = append(rows, "")
hdr := fmt.Sprintf("Selected: %s (%s, %s)",
devNameStyle.Render(st.selected.Path), st.selected.Size, dashIfEmpty(st.selected.FSType))
if st.isPool() {
hdr = fmt.Sprintf("Pool of %d drives, btrfs %s", len(st.pool), st.raidLevel)
}
rows = append(rows, hdr)
rows = append(rows, "")
rows = append(rows, "This becomes the folder name under /media/"+user+"/")
rows = append(rows, headerStyle.Render(
"e.g. \"MediaDrive\" → /media/"+user+"/MediaDrive"))
rows = append(rows, "")
rows = append(rows, " "+st.name.View())
rows = append(rows, "")
rows = append(rows, footerStyle.Render("type a label · enter confirm · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderAddDriveConfirm(user string, st *addDriveState, inv *drives.Inventory) string {
mp := filepath.Join("/media", user, st.name.Value())
var rows []string
rows = append(rows, titleStyle.Render("Mount this drive?"))
rows = append(rows, "")
rows = append(rows, fmt.Sprintf("Drive: %s (%s, %s)",
devNameStyle.Render(st.selected.Path), st.selected.Size, dashIfEmpty(st.selected.FSType)))
if st.formatAs != drives.FormatKeep {
rows = append(rows, "Format: "+
roleSystemStyle.Render(fmt.Sprintf("ERASE and mkfs.%s", st.formatAs)))
} else {
rows = append(rows, "Format: keep existing "+dashIfEmpty(st.selected.FSType))
}
// Existing-data preview — only on the Keep path, where there's data to
// reassure about. Built from a temporary read-only mount at name→confirm.
if st.formatAs == drives.FormatKeep && st.inspection != "" {
label := "Existing data: "
head := roleAvailableStyle.Render("kept as-is — nothing is erased")
if !st.hasContent {
head = headerStyle.Render("drive is empty")
}
rows = append(rows, label+head)
for _, l := range strings.Split(strings.TrimRight(st.inspection, "\n"), "\n") {
rows = append(rows, " "+headerStyle.Render(l))
}
}
// Wipe scope — three cases:
// 1. Whole-disk Drive: wipe is mandatory, no toggle. List its partitions.
// 2. Partition Drive, formatting: toggle between partition-only and whole-disk.
// 3. Keep existing fs: no wipe line at all.
if st.formatAs != drives.FormatKeep {
wholeDiskDrive := st.selected.Type == "disk"
if wholeDiskDrive {
rows = append(rows, "Wipe scope: "+roleSystemStyle.Render("[✓]")+
" Entire disk "+devNameStyle.Render(st.selected.Path)+
" — "+headerStyle.Render("mandatory (you picked a whole disk)"))
if inv != nil {
children := inv.ChildrenOf(st.selected.Name)
if len(children) > 0 {
rows = append(rows, " "+
roleSystemStyle.Render("Partitions on this disk that will be ERASED:"))
}
for _, c := range children {
line := fmt.Sprintf(" · %s (%s, %s%s)",
c.Path, c.Size, dashIfEmpty(c.FSType), labelSuffix(c.Label))
rows = append(rows, " "+roleSystemStyle.Render(line))
}
}
} else if st.selected.ParentDisk != "" {
mark := "[ ]"
scope := "Format only the picked partition (" + st.selected.Path + ")"
var detail []string
if st.wipeWholeDisk {
mark = roleSystemStyle.Render("[✓]")
parent := "/dev/" + st.selected.ParentDisk
scope = "Wipe ENTIRE parent disk " + parent + " first"
detail = append(detail, headerStyle.Render(fmt.Sprintf(
"Every byte of %s is erased; mkfs.%s and fstab use the whole disk.",
parent, st.formatAs)))
if inv != nil {
children := inv.ChildrenOf(st.selected.ParentDisk)
if len(children) > 0 {
detail = append(detail, roleSystemStyle.Render(" Partitions on this disk that will be ERASED:"))
}
for _, c := range children {
line := fmt.Sprintf(" · %s (%s, %s%s)",
c.Path, c.Size, dashIfEmpty(c.FSType), labelSuffix(c.Label))
if c.Path == st.selected.Path {
detail = append(detail, headerStyle.Render(line+" (picked)"))
} else {
detail = append(detail, roleSystemStyle.Render(line+" ⚠ also erased"))
}
}
}
}
rows = append(rows, "Wipe scope: "+mark+" "+scope+" "+headerStyle.Render("(w to toggle)"))
for _, l := range detail {
rows = append(rows, " "+l)
}
}
}
rows = append(rows, "Will appear: "+devNameStyle.Render(mp))
aclLine := "jellyfin user gets read access"
if st.formatAs == drives.FormatKeep && st.hasContent {
aclLine = "jellyfin granted read access across ALL existing files (recursive)"
}
rows = append(rows, "ACL grant: "+aclLine)
rows = append(rows, "fstab: new UUID-based entry added (backup taken first)")
if st.formatAs == drives.FormatKeep && st.hasContent {
rows = append(rows, "Starter dirs: "+headerStyle.Render("[ ] skipped — drive already has content"))
} else {
rows = append(rows, "Starter dirs: "+roleAvailableStyle.Render("[✓]")+" "+
fmt.Sprintf("%d folders created under JellyfinMedia/", len(drives.StarterFolders)))
rows = append(rows, " "+headerStyle.Render(strings.Join(drives.StarterFolders, ", ")))
}
rows = append(rows, "")
yes := " Yes, mount it "
no := " Cancel "
if st.confirmIdx == 0 {
yes = roleAvailableStyle.Render("▸" + yes)
no = " " + no
} else {
yes = " " + yes
no = roleSystemStyle.Render("▸" + no)
}
rows = append(rows, yes+" "+no)
rows = append(rows, "")
footer := "←/→ move · enter confirm · y/n shortcut · esc back"
if st.formatAs != drives.FormatKeep {
footer = "←/→ move · w wipe scope · enter confirm · y/n shortcut · esc back"
}
rows = append(rows, footerStyle.Render(footer))
return centeredCard(strings.Join(rows, "\n"))
}