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

323 lines
9.4 KiB
Go

package tui
import (
"fmt"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/drives"
)
// Sub-stages of the Import-existing-pool flow, switched via importPoolState.
type importSubStage int
const (
importPick importSubStage = iota // choose which detached pool (skipped if only one)
importName // text-input the mountpoint label
importConfirm // last-chance Yes/No before mounting
)
// importPoolState carries the import sub-flow's state across Update calls.
type importPoolState struct {
subStage importSubStage
pools []drives.DetachedPool
pickIdx int
selected drives.DetachedPool
name textinput.Model
confirmIdx int // 0 = Yes, 1 = No
inspection string // existing-data summary (from a temporary RO mount)
hasContent bool // pool already holds real data
}
// startImportPool enters the import flow. With no detached pools it flashes and
// stays on the menu; with exactly one it skips the picker and goes to naming.
func startImportPool(m Model) (Model, tea.Cmd, bool) {
var pools []drives.DetachedPool
if m.inventory != nil {
pools = m.inventory.DetachedPools()
}
if len(pools) == 0 {
m.flash = "No detached btrfs pools found to import."
return m, nil, true
}
ti := textinput.New()
ti.Prompt = ""
ti.Placeholder = "MediaPool"
ti.CharLimit = 32
ti.Width = 32
ti.Focus()
st := importPoolState{pools: pools, name: ti}
if len(pools) == 1 {
st.selected = pools[0]
st.subStage = importName
st.name.SetValue(defaultPoolLabel(pools[0]))
st.name.CursorEnd()
} else {
st.subStage = importPick
}
m.setup.importPool = st
m.setup.stage = stageImportPool
return m, nil, true
}
// defaultPoolLabel sanitises the pool's btrfs label into a directory name.
func defaultPoolLabel(p drives.DetachedPool) string {
if l := defaultLabelFrom(p.Label); l != "" {
return l
}
return "MediaPool"
}
// defaultLabelFrom reuses defaultLabelFor's sanitiser on a bare label string.
func defaultLabelFrom(label string) string {
return defaultLabelFor(drives.Drive{Label: label})
}
// --- key handling ---
func setupImportPoolKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
st := &m.setup.importPool
switch st.subStage {
case importPick:
return importPoolPickKey(m, msg.String())
case importName:
return importPoolNameKey(m, msg)
case importConfirm:
return importPoolConfirmKey(m, msg.String())
}
return m, nil, false
}
func importPoolPickKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup.importPool
switch key {
case "up", "k":
if st.pickIdx > 0 {
st.pickIdx--
}
return m, nil, true
case "down", "j":
if st.pickIdx < len(st.pools)-1 {
st.pickIdx++
}
return m, nil, true
case "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
st.selected = st.pools[st.pickIdx]
st.subStage = importName
st.name.SetValue(defaultPoolLabel(st.selected))
st.name.CursorEnd()
return m, nil, true
}
return m, nil, false
}
func importPoolNameKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
st := &m.setup.importPool
switch msg.String() {
case "esc":
// Back to the picker if there was a choice, else out to the menu.
if len(st.pools) > 1 {
st.subStage = importPick
} else {
m.setup.stage = stageIdle
}
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
}
// Preview the pool's contents via a temporary read-only mount so the
// confirm screen can reassure the user and we can decide whether to
// seed starter folders.
st.inspection, st.hasContent = drives.InspectDevice(st.selected.FirstDevice())
st.subStage = importConfirm
st.confirmIdx = 0
return m, nil, true
case "tab", "shift+tab":
return m, nil, false
}
var cmd tea.Cmd
st.name, cmd = st.name.Update(msg)
return m, cmd, true
}
func importPoolConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
st := &m.setup.importPool
switch key {
case "left", "h":
st.confirmIdx = 0
return m, nil, true
case "right", "l":
st.confirmIdx = 1
return m, nil, true
case "y", "Y":
st.confirmIdx = 0
return runImportPool(m)
case "n", "N", "esc":
m.setup.stage = stageIdle
return m, nil, true
case "enter":
if st.confirmIdx == 0 {
return runImportPool(m)
}
m.setup.stage = stageIdle
return m, nil, true
}
return m, nil, false
}
func runImportPool(m Model) (Model, tea.Cmd, bool) {
st := &m.setup.importPool
plan, err := drives.ImportPoolPlan(drives.ImportPoolOptions{
Pool: st.selected,
Label: st.name.Value(),
User: m.username,
// Don't litter an existing library with empty starter folders.
SeedStarterFolders: !st.hasContent,
// Pre-existing pool media may have been copied with tight perms — grant
// jellyfin read over the whole tree, not just the mount root.
RecursiveACL: st.hasContent,
})
if err != nil {
m.flash = "Couldn't plan the import: " + err.Error()
m.setup.stage = stageIdle
return m, nil, true
}
m.setup.stage = stageIdle
m.run = planRun{
title: fmt.Sprintf("Importing btrfs pool at /media/%s/%s", m.username, st.name.Value()),
steps: plan,
index: 0,
}
m.runStage = runRunning
m.runOwnerTab = tabSetup
return m, runStepCmd(plan[0]), true
}
// --- rendering ---
func renderImportPool(m Model) string {
st := &m.setup.importPool
switch st.subStage {
case importPick:
return renderImportPoolPick(st)
case importName:
return renderImportPoolName(m.username, st)
case importConfirm:
return renderImportPoolConfirm(m.username, st)
}
return ""
}
func renderImportPoolPick(st *importPoolState) string {
var rows []string
rows = append(rows, titleStyle.Render("Import existing btrfs pool"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"These pools were found whole and unmounted. Importing mounts one as-is — nothing is erased."))
rows = append(rows, "")
for i, p := range st.pools {
marker := " "
head := fmt.Sprintf("%s (%d devices, %s)",
p.DisplayLabel(), len(p.Members), devNameStyle.Render(shortUUID(p.UUID)))
if i == st.pickIdx {
marker = roleAvailableStyle.Render("▸ ")
head = roleAvailableStyle.Render(head)
}
rows = append(rows, marker+head)
for _, d := range p.Members {
rows = append(rows, " "+headerStyle.Render("· "+d.Path+" ("+d.Size+")"))
}
rows = append(rows, "")
}
rows = append(rows, footerStyle.Render("↑/↓ move · enter select · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderImportPoolName(user string, st *importPoolState) string {
var rows []string
rows = append(rows, titleStyle.Render("Name this pool"))
rows = append(rows, "")
rows = append(rows, fmt.Sprintf("Pool: %s (%d devices, btrfs)",
st.selected.DisplayLabel(), len(st.selected.Members)))
rows = append(rows, "")
rows = append(rows, "This becomes the folder name under /media/"+user+"/")
rows = append(rows, headerStyle.Render(
"e.g. \"MediaPool\" → /media/"+user+"/MediaPool"))
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 renderImportPoolConfirm(user string, st *importPoolState) string {
mp := filepath.Join("/media", user, st.name.Value())
var rows []string
rows = append(rows, titleStyle.Render("Import this pool?"))
rows = append(rows, "")
rows = append(rows, "Pool: "+st.selected.DisplayLabel()+
" ("+devNameStyle.Render(shortUUID(st.selected.UUID))+")")
rows = append(rows, "Member devices:")
for _, d := range st.selected.Members {
rows = append(rows, " · "+devNameStyle.Render(d.Path)+" ("+d.Size+")")
}
rows = append(rows, "Action: "+roleAvailableStyle.Render("mount as-is — nothing is formatted or erased"))
if st.inspection != "" {
label := "Existing data: "
head := roleAvailableStyle.Render("kept as-is")
if !st.hasContent {
head = headerStyle.Render("pool is empty")
}
rows = append(rows, label+head)
for _, l := range strings.Split(strings.TrimRight(st.inspection, "\n"), "\n") {
rows = append(rows, " "+headerStyle.Render(l))
}
}
rows = append(rows, "Will appear: "+devNameStyle.Render(mp))
acl := "jellyfin user gets read access"
if st.hasContent {
acl = "jellyfin granted read access across ALL existing files (recursive)"
}
rows = append(rows, "ACL grant: "+acl)
rows = append(rows, "fstab: UUID-based, mounts the pool via the first device")
starter := roleAvailableStyle.Render("[✓]") + " " +
fmt.Sprintf("%d folders created under JellyfinMedia/", len(drives.StarterFolders))
if st.hasContent {
starter = headerStyle.Render("[ ] skipped — pool already has content")
}
rows = append(rows, "Starter dirs: "+starter)
rows = append(rows, "")
yes := " Yes, import 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, "")
rows = append(rows, footerStyle.Render("←/→ move · enter confirm · y/n shortcut · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
// shortUUID trims a UUID to its first segment for compact display.
func shortUUID(u string) string {
if i := strings.IndexByte(u, '-'); i > 0 {
return u[:i] + "…"
}
return u
}