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>
This commit is contained in:
28allday 2026-05-31 16:34:16 +01:00
commit e02c66ab46
33 changed files with 8607 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/dist/
/tuistream
/smoketest
*.test
*.out
# internal working notes — not shipped
/NEXT_PHASE.md
/scratch/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gavin Nugent
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# TUISTREAM (`tuistream`)
A **terminal UI** for setting up and running a **headless [Jellyfin](https://jellyfin.org)
media server** on Arch / [Omarchy](https://omarchy.org) — over SSH, with no
desktop environment. It installs Jellyfin, attaches and mounts media drives
(single disks or btrfs RAID pools), opens the firewall, and shows a live health
monitor, all without dropping you to a shell or switching terminals mid-task.
> TUISTREAM is an independent setup/management tool for Jellyfin. It is not
> affiliated with the Jellyfin project; "Jellyfin" is used here descriptively.
## Features
- **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.
- **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
classifier follows every candidate down through LUKS / LVM / RAID to its
physical disk and refuses anything hosting `/`, `/boot`, or swap.
- **Import an existing btrfs pool** — re-attach a multi-device pool from a
previous setup **non-destructively** (mounts it as-is; never formats).
- **Safe with existing media** — keeping a filesystem previews what's on it,
skips creating starter folders when it already has content, and grants Jellyfin
read access recursively so existing media is actually visible to the scanner.
- **Manage** — copy media in from an external drive (with a file picker),
mount / eject drives, and rename or delete files, each behind a confirmation.
- **Monitor** — per-core CPU, load / memory / uptime, per-mount capacity, btrfs
pool error counters, and per-disk SMART health — for a box you only reach over
SSH.
- **Single static binary**; missing tools (btrfs-progs, xfsprogs, acl, …) are
installed on first launch.
## Install
One line, nothing to clone:
```sh
curl -fsSL https://raw.githubusercontent.com/28allday/TUISTREAM/main/install.sh | bash
```
This installs the right binary for your architecture into `/usr/local/bin`
(using `sudo` if needed) so both `tuistream` and `sudo tuistream` resolve. Pin a
version with `TUISTREAM_VERSION=v0.1.0`, or change the prefix with `PREFIX=/opt`.
TUISTREAM installs to a **system** path on purpose: it's a root-by-design tool
(installs Jellyfin, edits `/etc/fstab`, mounts drives), so it must be on the sudo
`secure_path``~/.local/bin` is not.
## Usage
```sh
sudo tuistream # full TUI
tuistream --read-only # inventory views only, no root, no actions
```
Run with `sudo`, it acts on behalf of the real user behind the sudo invocation
(`$SUDO_USER`): media drives mount under `/media/<user>/` and its Omarchy theme
is used. `tab` / `shift+tab` switch tabs, `r` refreshes, `q` quits; each tab
shows its key bar at the bottom.
## Getting started
A first-run, from an empty box to a working server — all from the **Setup** tab
(`sudo tuistream`):
1. **Install Jellyfin** — press `i` and confirm. TUISTREAM pulls Jellyfin from
the official Arch repo and starts the service.
2. **Attach storage for your media**:
- A spare disk → press `a` (**add drive**), pick it, choose *Keep existing
filesystem* or format it, and name it. It mounts at
`/media/<user>/<name>`.
- A multi-disk btrfs pool from a previous setup → press `p` (**import pool**)
to re-attach it untouched. (`p` only shows when such a pool is present.)
- On a fresh drive, TUISTREAM seeds library folders for you under
`<mount>/JellyfinMedia/`: `Movies`, `Shows`, `Music`, `Books`,
`Home Videos`, `Music Videos`. Copy your media into these (the **Manage**
tab's `c` can copy in from an external drive).
3. **Open the firewall** — press `f` so other devices on your LAN can reach the
server (ports `8096/tcp` web UI and `7359/udp` discovery).
4. **Open the web UI** — press `y` to copy the server's address
(`http://<host>:8096`) to your clipboard, even over SSH, then open it in a
browser on any LAN device.
5. **Create your libraries in Jellyfin** — in the web UI's first-run wizard, add
a library for each type and point it at the matching folder under
`/media/<user>/<name>/JellyfinMedia/` (e.g. *Movies*`…/JellyfinMedia/Movies`).
The folder names already match Jellyfin's content types.
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).
## Build from source
Needs Go 1.26+.
```sh
git clone https://github.com/28allday/TUISTREAM
cd TUISTREAM
./install.sh # builds, then installs to /usr/local/bin
```
## License
MIT — see [LICENSE](LICENSE). TUISTREAM runs Jellyfin (GPL-2.0) as a separate
process; it does not bundle or link it. The terminal UI is built on the
[Charm](https://github.com/charmbracelet) libraries (MIT).

59
cmd/tuistream/main.go Normal file
View file

@ -0,0 +1,59 @@
// Command tuistream is a TUI for setting up and managing a headless Jellyfin
// Media Server on Omarchy / Arch Linux.
//
// Two tabs:
//
// Setup — install Jellyfin, attach media drives, firewall, uninstall.
// Manage — copy files from an external drive to a media drive,
// delete files, rename files.
//
// The picker hides any partition that lives on a disk hosting the OS or
// active swap, so the boot drive can never be selected as a media drive.
package main
import (
"flag"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/system"
"tuistream/internal/theme"
"tuistream/internal/tui"
)
func main() {
readOnly := flag.Bool("read-only", false,
"open the TUI without checking for root; only the inventory views work")
flag.Parse()
if !*readOnly && os.Geteuid() != 0 {
fmt.Fprintln(os.Stderr,
"tuistream needs administrator rights to install Jellyfin, edit /etc/fstab, etc.")
fmt.Fprintln(os.Stderr, " Run: sudo tuistream")
fmt.Fprintln(os.Stderr, " Or: tuistream --read-only (inventory only, no actions)")
os.Exit(1)
}
// Pre-flight: install any userspace tools the flows assume are present.
// Only attempted when running as root — read-only just skips the check.
if !*readOnly {
if err := system.EnsureInstalled(); err != nil {
fmt.Fprintln(os.Stderr, "tuistream: pacman failed:", err)
fmt.Fprintln(os.Stderr,
" Some actions may not work until the missing packages are installed.")
}
}
m := tui.NewModel(theme.Load())
// No mouse capture: TUISTREAM is keyboard-driven, and grabbing the mouse
// would stop the user's terminal/tmux from selecting-to-copy text (e.g.
// the Jellyfin web URL) or click-opening links. Leaving it off keeps
// native selection and hyperlink handling working over SSH + tmux.
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "tuistream:", err)
os.Exit(1)
}
}

33
go.mod Normal file
View file

@ -0,0 +1,33 @@
module tuistream
go 1.26.1
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

52
go.sum Normal file
View file

@ -0,0 +1,52 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

104
install.sh Executable file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# install.sh — install TUISTREAM, a TUI for a headless Jellyfin server on
# Omarchy / Arch.
#
# Quick install (nothing to clone — the Once way):
#
# curl -fsSL https://raw.githubusercontent.com/28allday/TUISTREAM/main/install.sh | bash
#
# When run from a git clone it builds from source instead (if Go is present),
# otherwise it downloads the latest released binary for your architecture.
#
# ./install.sh
#
# Environment overrides:
# PREFIX=/somewhere install prefix (default /usr/local → /usr/local/bin)
# TUISTREAM_VERSION=v0.1.0 pin a release (default: latest)
#
# Why /usr/local/bin (not ~/.local/bin): TUISTREAM is a root-by-design tool —
# it installs Jellyfin, edits /etc/fstab and mounts drives, so it's run as
# `sudo tuistream`. /usr/local/bin is on the default Arch sudo secure_path;
# ~/.local/bin is NOT, so a user-local copy wouldn't be found under sudo.
set -euo pipefail
REPO="28allday/TUISTREAM"
PREFIX="${PREFIX:-/usr/local}"
BIN_DIR="$PREFIX/bin"
NAME="tuistream"
BIN="$BIN_DIR/$NAME"
VERSION="${TUISTREAM_VERSION:-latest}"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
# If the script lives next to the source tree, we're in a clone.
SCRIPT_DIR=""
if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fi
# Writing to a system prefix needs root; fall back to sudo when we're not it.
SUDO=""
if [ ! -w "$BIN_DIR" ]; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
die "cannot write $BIN_DIR and sudo not available — re-run as root."
fi
fi
$SUDO mkdir -p "$BIN_DIR"
# ---- obtain the binary ---------------------------------------------------
TMPBIN=""
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
log "Building $NAME from source..."
TMPBIN="$(mktemp)"
trap 'rm -f "$TMPBIN"' EXIT
( cd "$SCRIPT_DIR" && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' \
-o "$TMPBIN" "./cmd/$NAME" )
elif [ -n "$SCRIPT_DIR" ] && [ -x "$SCRIPT_DIR/dist/${NAME}-linux-amd64" ]; then
log "Using prebuilt binary from dist/"
TMPBIN="$SCRIPT_DIR/dist/${NAME}-linux-amd64"
else
# Download the released binary for this OS/arch (curl-style install).
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
[ "$os" = "linux" ] || die "TUISTREAM ships Linux binaries only (detected: $os). Clone the repo and build with Go."
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) die "unsupported architecture: $(uname -m)" ;;
esac
asset="${NAME}-${os}-${arch}"
if [ "$VERSION" = "latest" ]; then
url="https://github.com/$REPO/releases/latest/download/$asset"
else
url="https://github.com/$REPO/releases/download/$VERSION/$asset"
fi
log "Downloading $asset ($VERSION)..."
TMPBIN="$(mktemp)"
trap 'rm -f "$TMPBIN"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fSL --proto '=https' --tlsv1.2 -o "$TMPBIN" "$url"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$TMPBIN" "$url"
else
die "need curl or wget to download the binary."
fi
fi
# ---- install -------------------------------------------------------------
log "Installing to $BIN"
$SUDO install -Dm755 "$TMPBIN" "$BIN"
# Remove any stale ~/.local/bin copy that would shadow the system one under a
# non-sudo PATH (older installs landed there).
OLD="$HOME/.local/bin/$NAME"
if [ -e "$OLD" ]; then
log "Removing stale user-local copy: $OLD"
rm -f "$OLD"
fi
echo
log "Done. Run it with: sudo $NAME"

610
internal/drives/detect.go Normal file
View file

@ -0,0 +1,610 @@
// Package drives detects block devices on the host and classifies each one.
//
// The classifier is the safety mechanism that prevents the boot drive from
// ever being offered as a media drive: it walks every candidate partition
// down through any LUKS / LVM / RAID layers to its underlying physical disk
// and refuses to offer it if that disk also hosts /, /boot, swap, or any of
// the other "system" mounts.
package drives
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// probeTimeout caps each block-device probe. A half-connected or failing USB
// drive can make lsblk/findmnt/mountpoint block in uninterruptible kernel I/O,
// which would otherwise freeze launch (detection runs before the first paint).
// On timeout the probe reports an error and detection degrades gracefully.
const probeTimeout = 8 * time.Second
// cmdOutput runs a command with a deadline and returns its stdout. On timeout
// the process is killed and a non-nil error is returned.
func cmdOutput(name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
return exec.CommandContext(ctx, name, args...).Output()
}
// cmdRunOK reports whether a command exits 0 within the deadline.
func cmdRunOK(name string, args ...string) bool {
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
return exec.CommandContext(ctx, name, args...).Run() == nil
}
// Role labels what a partition is currently being used for. Used both in the
// inventory display and to filter candidates in the picker.
type Role string
const (
RoleSystem Role = "system" // on a disk that hosts /, /boot, swap, etc.
RoleSwap Role = "swap" // swap partition
RoleLUKS Role = "luks" // crypto_LUKS container
RoleLVM Role = "lvm-pv" // LVM physical volume
RoleRAID Role = "raid" // mdadm RAID member
RoleMounted Role = "mounted" // mounted at some non-system path
RoleAvailable Role = "available" // has a filesystem, nowhere mounted — pickable
RoleEmpty Role = "empty" // no filesystem at all
RoleManagedOurs Role = "managed-ours" // we put this in fstab under /media/<user>/
RolePoolMember Role = "pool-member" // device of a multi-device btrfs pool (mounted via a sibling) — off-limits
)
// Drive represents one block device row from lsblk.
type Drive struct {
Path string // /dev/sda1
Name string // sda1
Size string // "3.6T"
FSType string // ext4, btrfs, swap, crypto_LUKS, "" if none
Label string // filesystem label, "" if none
UUID string // filesystem UUID
Model string // device model (top-level disks only)
Transport string // sata / usb / nvme (top-level disks only)
Type string // disk / part / crypt / lvm
MountPoint string // "" if not mounted
ParentDisk string // physical disk this lives on (sda, nvme0n1) — "" for top-level disks
Hotpluggable bool // best-effort: USB / removable
Role Role // computed
RoleDetail string // free-text like "→ /boot" or "encrypted (LUKS)"
}
// Inventory is the full list of drives on the system, in lsblk order, with
// each row's Role and ParentDisk filled in. Calling site uses this for the
// Setup tab's overview panel and for filtering candidates.
type Inventory struct {
All []Drive
// SystemDisks is the set of physical disks the OS lives on. Their short
// names (sda, nvme0n1) — never offered as media targets.
SystemDisks map[string]bool
}
// Load runs lsblk and findmnt, returns a fully-classified Inventory.
func Load(managedUser string) (*Inventory, error) {
sysDisks, err := systemDisks()
if err != nil {
return nil, err
}
managed := managedMounts(managedUser)
all, err := lsblkAll()
if err != nil {
return nil, err
}
// First pass: fill ParentDisk for every row.
for i := range all {
if all[i].Type == "disk" {
all[i].ParentDisk = ""
continue
}
all[i].ParentDisk = physicalDiskOf(all[i].Path)
}
// Multi-device btrfs filesystems share one UUID across every member device.
// Map UUID → device count and UUID → "is any member mounted", so the
// classifier can recognise a pool member that has no mountpoint of its own
// (e.g. a 2-disk RAID mounted via /dev/sda while /dev/sdb looks idle).
uuidCount := map[string]int{}
uuidMounted := map[string]bool{}
for i := range all {
u := all[i].UUID
if u == "" {
continue
}
uuidCount[u]++
if all[i].MountPoint != "" {
uuidMounted[u] = true
}
}
// Second pass: classify Role. Membership of a system disk always wins so
// the UI consistently marks every partition on the boot drive as off-limits,
// regardless of whether it's mounted, encrypted, or empty.
for i := range all {
d := &all[i]
switch d.Type {
case "disk":
if sysDisks[d.Name] {
d.Role = RoleSystem
d.RoleDetail = "SYSTEM DISK (off-limits)"
continue
}
// Whole disk with a filesystem directly on it (no partition
// table) — e.g. after `mkfs.btrfs /dev/sda`. Apply the same
// mount/managed classification we use for partitions.
if d.MountPoint != "" {
if managed[d.MountPoint] {
d.Role = RoleManagedOurs
d.RoleDetail = "TUISTREAM media → " + d.MountPoint
} else {
d.Role = RoleMounted
d.RoleDetail = "mounted → " + d.MountPoint
}
continue
}
if d.FSType != "" {
if member, detail := btrfsPoolMember(d, uuidCount, uuidMounted); member {
d.Role = RolePoolMember
d.RoleDetail = detail
} else {
// Formatted but unmounted (e.g. partial setup, manual umount).
d.Role = RoleAvailable
d.RoleDetail = "AVAILABLE"
}
}
continue
case "part", "crypt":
// fall through
default:
continue
}
// What the partition "looks like" on its own — used to make the
// RoleSystem detail informative when we override below.
intrinsic := ""
intrinsicRole := Role("")
switch d.FSType {
case "swap":
intrinsic, intrinsicRole = "swap", RoleSwap
case "crypto_LUKS":
intrinsic, intrinsicRole = "encrypted (LUKS container)", RoleLUKS
case "LVM2_member":
intrinsic, intrinsicRole = "LVM physical volume", RoleLVM
case "linux_raid_member":
intrinsic, intrinsicRole = "RAID member", RoleRAID
}
// System-disk membership trumps everything else.
if sysDisks[d.ParentDisk] {
d.Role = RoleSystem
switch {
case d.MountPoint != "":
d.RoleDetail = "boot disk — mounted at " + d.MountPoint
case intrinsic != "":
d.RoleDetail = "boot disk — " + intrinsic
default:
d.RoleDetail = "boot disk — off-limits"
}
continue
}
// Off the boot disk: classify normally.
if d.MountPoint != "" {
if managed[d.MountPoint] {
d.Role = RoleManagedOurs
d.RoleDetail = "TUISTREAM media → " + d.MountPoint
} else {
d.Role = RoleMounted
d.RoleDetail = "mounted → " + d.MountPoint
}
continue
}
if intrinsicRole != "" {
d.Role = intrinsicRole
d.RoleDetail = intrinsic
continue
}
if d.FSType == "" {
d.Role = RoleEmpty
d.RoleDetail = "empty / unformatted"
} else if member, detail := btrfsPoolMember(d, uuidCount, uuidMounted); member {
d.Role = RolePoolMember
d.RoleDetail = detail
} else {
d.Role = RoleAvailable
d.RoleDetail = "AVAILABLE"
}
}
return &Inventory{All: all, SystemDisks: sysDisks}, nil
}
// Candidates returns drives suitable for being added as a media drive.
//
// Three kinds qualify, in lsblk order:
//
// 1. Whole disks that aren't system disks AND have no currently-mounted
// children. Includes the "freshly-wiped, no partition table" case
// (where lsblk shows just the bare disk with no children) as well as
// "blank USB that's never been partitioned".
// 2. Partitions with a real filesystem that aren't mounted anywhere
// (RoleAvailable).
// 3. Empty/unformatted partitions on a non-system disk (RoleEmpty) —
// the user can format them during Add.
//
// If a disk and one of its partitions both qualify, both appear; the picker
// auto-deselects any conflicting peer when the user toggles a row.
func (inv *Inventory) Candidates() []Drive {
var out []Drive
for _, d := range inv.All {
switch d.Type {
case "disk":
if !inv.diskIsCandidate(d) {
continue
}
out = append(out, d)
case "part", "crypt":
if d.Role == RoleAvailable || d.Role == RoleEmpty {
out = append(out, d)
}
}
}
return out
}
// IsPseudoDisk reports whether a whole-disk device is a kernel/firmware
// pseudo-device that should never be shown or offered as a usable drive:
// zram swap, loopback mounts, device-mapper targets, and the tiny eMMC
// hardware boot / RPMB areas (mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb …).
// Exported so the TUI and the health monitor apply the exact same filter.
func IsPseudoDisk(name string) bool {
switch {
case strings.HasPrefix(name, "zram"),
strings.HasPrefix(name, "loop"),
strings.HasPrefix(name, "dm-"):
return true
case strings.HasPrefix(name, "mmcblk") &&
(strings.Contains(name, "boot") || strings.Contains(name, "rpmb")):
return true
}
return false
}
// diskIsCandidate is the gate for whole-disk picker eligibility. We refuse
// system disks, virtual / firmware pseudo-disks (zram / loop / dm / eMMC boot
// areas), and any disk that has ANY currently-mounted child (mount-point != "")
// so the user can't nuke storage that's actively in use.
func (inv *Inventory) diskIsCandidate(d Drive) bool {
if inv.SystemDisks[d.Name] {
return false
}
if IsPseudoDisk(d.Name) {
return false
}
// A whole-disk btrfs that's a live pool member (mounted via a sibling disk).
if d.Role == RolePoolMember {
return false
}
for _, c := range inv.All {
if c.ParentDisk != d.Name {
continue
}
// Any mounted child, or any child that belongs to a btrfs pool, means
// wiping this disk would destroy storage that's actively in use.
if c.MountPoint != "" || c.Role == RolePoolMember {
return false
}
}
return true
}
// btrfsPoolMember reports whether an unmounted, formatted device is actually a
// member of a multi-device btrfs filesystem — recognised because btrfs gives
// every device of one filesystem the same UUID. Two cases qualify:
//
// - another device sharing this UUID is currently mounted (a live pool
// reached through a sibling, e.g. a 2-disk RAID mounted via /dev/sda while
// /dev/sdb shows no mountpoint of its own), or
// - two or more devices share the UUID (a detached multi-device pool).
//
// Such a device must never be offered for formatting: wiping any one member
// destroys the whole pool. Returns a human-readable detail for the inventory.
func btrfsPoolMember(d *Drive, uuidCount map[string]int, uuidMounted map[string]bool) (bool, string) {
if d.FSType != "btrfs" || d.UUID == "" {
return false, ""
}
name := d.Label
if name == "" {
name = "(unlabelled)"
}
switch {
case uuidMounted[d.UUID]:
return true, "IN USE — btrfs pool '" + name + "' (mounted via another device)"
case uuidCount[d.UUID] >= 2:
return true, "IN USE — btrfs pool '" + name + "' (multi-device, detached)"
}
return false, ""
}
// Managed returns drives the installer has previously set up (mounted under
// /media/<user>/). Used by the Manage tab as the destination list.
func (inv *Inventory) Managed() []Drive {
var out []Drive
for _, d := range inv.All {
if d.Role == RoleManagedOurs {
out = append(out, d)
}
}
return out
}
// FindDisk returns the whole-disk Drive whose short name matches `name`
// (e.g. "sda" or "nvme0n1"), or nil if none. Used by the Add-drive flow when
// auto-collapsing same-parent selections into whole-disk single-drive mode.
func (inv *Inventory) FindDisk(name string) *Drive {
for i := range inv.All {
d := &inv.All[i]
if d.Type == "disk" && d.Name == name {
return d
}
}
return nil
}
// ChildrenOf returns every partition / crypt mapping whose underlying
// physical disk is `parentName` (e.g. "sda"). Used by the Add-drive confirm
// screen to list what would be erased if WipeWholeDisks is set.
func (inv *Inventory) ChildrenOf(parentName string) []Drive {
var out []Drive
for _, d := range inv.All {
if d.Type == "disk" {
continue
}
if d.ParentDisk == parentName {
out = append(out, d)
}
}
return out
}
// UniqueParents returns the set of parent-disk names referenced by the
// passed drives, in first-seen order. Empty entries (drives that have no
// resolvable parent, like top-level disks themselves) are skipped.
func UniqueParents(ds []Drive) []string {
seen := map[string]bool{}
var out []string
for _, d := range ds {
if d.ParentDisk == "" || seen[d.ParentDisk] {
continue
}
seen[d.ParentDisk] = true
out = append(out, d.ParentDisk)
}
return out
}
// Mountable returns drives that have a recognisable filesystem on them but
// aren't currently mounted anywhere. Used by the Manage tab's "mount drive"
// flow on headless boxes where no auto-mount daemon runs. System disks and
// partitions on system disks are already excluded by the role classifier.
func (inv *Inventory) Mountable() []Drive {
supported := map[string]bool{
"ext4": true, "btrfs": true, "xfs": true,
"vfat": true, "exfat": true, "ntfs": true, "ntfs3": true,
"iso9660": true, "udf": true, "f2fs": true,
}
var out []Drive
for _, d := range inv.All {
if d.Role != RoleAvailable {
continue
}
if !supported[d.FSType] {
continue
}
out = append(out, d)
}
return out
}
// External returns mounted drives that are NOT system mounts and NOT managed
// by us — anything the user has plugged in and that the desktop has
// auto-mounted (e.g. a USB stick at /run/media/<user>/MovieDump). Used by
// the Manage tab as the copy-source list.
func (inv *Inventory) External() []Drive {
var out []Drive
for _, d := range inv.All {
if d.Role != RoleMounted {
continue
}
// Anything mounted under /run/media, /media, /mnt is plausibly external.
mp := d.MountPoint
if strings.HasPrefix(mp, "/run/media/") || strings.HasPrefix(mp, "/media/") || strings.HasPrefix(mp, "/mnt/") {
out = append(out, d)
}
}
return out
}
// ---------------- internals ----------------
// lsblk's JSON output structure. We only model the fields we need.
type lsblkNode struct {
Name string `json:"name"` // bare name, e.g. "sda1"
Path string `json:"path"` // full path, e.g. "/dev/sda1"
Size string `json:"size"` // "3.6T"
FSType string `json:"fstype"` // ext4, swap, crypto_LUKS, ""
Label string `json:"label"` // fs label
UUID string `json:"uuid"` // fs uuid
Mountpoint string `json:"mountpoint"` // single-mountpoint field (older lsblk)
Mountpoints []string `json:"mountpoints"` // newer lsblk uses an array
Model string `json:"model"` // disk model (top-level only)
Tran string `json:"tran"` // transport (sata/usb/nvme)
Type string `json:"type"` // disk/part/crypt/lvm
RM bool `json:"rm"` // removable flag
HotPlug bool `json:"hotplug"` // hotplug flag
Children []lsblkNode `json:"children"`
}
func lsblkAll() ([]Drive, error) {
// No -b: SIZE comes back as a human-readable string like "3.6T".
out, err := cmdOutput(
"lsblk", "-J", "-p", "-o",
"NAME,PATH,SIZE,FSTYPE,LABEL,UUID,MOUNTPOINT,MOUNTPOINTS,MODEL,TRAN,TYPE,RM,HOTPLUG",
)
if err != nil {
// Older lsblk doesn't know MOUNTPOINTS — retry without it.
out, err = cmdOutput(
"lsblk", "-J", "-p", "-o",
"NAME,PATH,SIZE,FSTYPE,LABEL,UUID,MOUNTPOINT,MODEL,TRAN,TYPE,RM,HOTPLUG",
)
if err != nil {
return nil, err
}
}
var root struct {
BlockDevices []lsblkNode `json:"blockdevices"`
}
if err := json.Unmarshal(out, &root); err != nil {
return nil, err
}
var flat []Drive
for _, n := range root.BlockDevices {
flat = append(flat, flatten(n, "")...)
}
return flat, nil
}
func flatten(n lsblkNode, _ string) []Drive {
d := Drive{
Path: n.Path,
Name: strings.TrimPrefix(n.Path, "/dev/"),
Size: n.Size,
FSType: n.FSType,
Label: n.Label,
UUID: n.UUID,
Model: n.Model,
Transport: n.Tran,
Type: n.Type,
Hotpluggable: n.RM || n.HotPlug,
}
d.MountPoint = n.Mountpoint
if d.MountPoint == "" && len(n.Mountpoints) > 0 {
for _, mp := range n.Mountpoints {
if mp != "" {
d.MountPoint = mp
break
}
}
}
out := []Drive{d}
for _, c := range n.Children {
out = append(out, flatten(c, n.Name)...)
}
return out
}
// physicalDiskOf walks a device down to its underlying physical disk.
// Returns the disk's short name (e.g. "sda", "nvme0n1") or "" if unresolved.
func physicalDiskOf(devPath string) string {
out, err := cmdOutput("lsblk", "-s", "-n", "-l", "-o", "NAME,TYPE", devPath)
if err != nil {
return ""
}
sc := bufio.NewScanner(strings.NewReader(string(out)))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) >= 2 && fields[len(fields)-1] == "disk" {
return fields[0]
}
}
return ""
}
// systemDisks returns the set of physical disks that host any system mount
// or active swap. Names are short (sda, nvme0n1).
func systemDisks() (map[string]bool, error) {
out := map[string]bool{}
mounts := []string{
"/", "/boot", "/boot/efi", "/efi", "/home", "/usr",
"/var", "/var/lib", "/var/log", "/tmp", "/opt", "/srv", "/nix",
}
for _, mp := range mounts {
src := findmntSource(mp)
if src == "" {
continue
}
if disk := physicalDiskOf(src); disk != "" {
out[disk] = true
}
}
// Swap from /proc/swaps
if f, err := os.Open("/proc/swaps"); err == nil {
defer f.Close()
sc := bufio.NewScanner(f)
first := true
for sc.Scan() {
if first { // header row
first = false
continue
}
fields := strings.Fields(sc.Text())
if len(fields) == 0 || strings.HasPrefix(fields[0], "#") {
continue
}
dev := fields[0]
if !strings.HasPrefix(dev, "/dev/") {
continue
}
if _, err := os.Stat(dev); err != nil {
continue
}
if disk := physicalDiskOf(dev); disk != "" {
out[disk] = true
}
}
}
return out, nil
}
func findmntSource(mp string) string {
out, err := cmdOutput("findmnt", "-no", "SOURCE", mp)
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// managedMounts returns the set of mountpoints under /media/<user>/ that this
// installer has previously added via fstab. Best-effort: we just enumerate
// the directories under /media/<user>/ that are currently mounted.
func managedMounts(user string) map[string]bool {
out := map[string]bool{}
if user == "" {
return out
}
root := filepath.Join("/media", user)
entries, err := os.ReadDir(root)
if err != nil {
return out
}
for _, e := range entries {
if !e.IsDir() {
continue
}
mp := filepath.Join(root, e.Name())
// Treat as managed if something is mounted there.
if cmdRunOK("mountpoint", "-q", mp) {
out[mp] = true
}
}
return out
}

57
internal/drives/format.go Normal file
View file

@ -0,0 +1,57 @@
package drives
import (
"fmt"
"os/exec"
"tuistream/internal/step"
)
// FormatChoice names one of the filesystems the user can put on a media
// drive. Empty string = "don't format, keep what's there".
type FormatChoice string
const (
FormatKeep FormatChoice = "" // use the existing filesystem as-is
FormatExt4 FormatChoice = "ext4" // widely compatible, solid default
FormatBtrfs FormatChoice = "btrfs" // snapshots, multi-device growth, scrub
FormatXFS FormatChoice = "xfs" // great for very large files
)
// FormatStep returns a single step that wipes the device and lays down a
// fresh filesystem of `fs`. `label` is applied as the filesystem label so
// `blkid` shows it and the inventory's "Label" column populates.
func FormatStep(devicePath, label string, fs FormatChoice) (step.Step, bool) {
if fs == FormatKeep {
return step.Step{}, false
}
var args []string
var bin string
switch fs {
case FormatExt4:
bin = "mkfs.ext4"
args = []string{"-F"}
if label != "" {
args = append(args, "-L", label)
}
case FormatBtrfs:
bin = "mkfs.btrfs"
args = []string{"-f"}
if label != "" {
args = append(args, "-L", label)
}
case FormatXFS:
bin = "mkfs.xfs"
args = []string{"-f"}
if label != "" {
args = append(args, "-L", label)
}
default:
return step.Step{}, false
}
args = append(args, devicePath)
return step.Step{
Title: fmt.Sprintf("Format %s as %s (label=%q) — ERASES existing data", devicePath, fs, label),
Cmd: exec.Command(bin, args...),
}, true
}

450
internal/drives/mount.go Normal file
View file

@ -0,0 +1,450 @@
package drives
import (
"bufio"
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
"time"
"tuistream/internal/step"
)
// MountOptions captures the user's choices for an Add-drive run.
type MountOptions struct {
Drive Drive // the chosen partition
Label string // friendly name (becomes the directory under /media/<user>/)
User string // target user (SUDO_USER); ACLs and ownership go to this user
UserGroup string // primary group of User; "" → resolve via getent
FormatAs FormatChoice // "" = keep existing fs; otherwise mkfs.<choice> with -F/-f
// WipeWholeDisk: nuke the entire parent disk (zap GPT, wipefs every
// signature, partprobe) before mkfs, and use the whole-disk path for
// mkfs and the fstab UUID lookup. Only meaningful when FormatAs is set —
// "keep existing" is a no-op for wipe. Destroys any OTHER partitions on
// the parent disk; the confirm screen lists them so this is explicit.
WipeWholeDisk bool
// SeedStarterFolders creates the Jellyfin library starter folders
// (StarterFolders) under <mount>/JellyfinMedia/. The TUI leaves this off
// when keeping an existing filesystem that already holds the user's own
// content, so we don't litter their layout with empty library dirs.
SeedStarterFolders bool
// RecursiveACL grants the jellyfin user read access over the ENTIRE existing
// tree (setfacl -R), not just the mount root + future files. Set when we're
// keeping a drive that already holds media: a non-recursive grant would
// leave pre-existing files unreadable to Jellyfin if they were copied with
// tight permissions. Harmless (read-only grant) but can take a moment on a
// very large library, so it's only switched on when there's content to fix.
RecursiveACL bool
}
// EffectiveFSType returns the filesystem the drive will have AFTER the plan
// runs — i.e. the formatted-to choice if we're formatting, else the
// existing filesystem.
func (o MountOptions) EffectiveFSType() string {
if o.FormatAs != FormatKeep {
return string(o.FormatAs)
}
return o.Drive.FSType
}
// EffectiveTargetDevice returns the block-device path mkfs/blkid/mount will
// actually operate on. If WipeWholeDisk is set AND a parent disk was
// resolved, that's the whole disk (/dev/sda); for a whole-disk Drive
// (Type=="disk") it's already the disk; otherwise it's the chosen partition.
func (o MountOptions) EffectiveTargetDevice() string {
if o.WipeWholeDisk && o.Drive.ParentDisk != "" {
return "/dev/" + o.Drive.ParentDisk
}
return o.Drive.Path
}
// WipeTarget returns the path of the whole-disk wipe step's victim, or "" if
// no wipe step should run. We wipe whenever the user chose a destructive
// format AND either explicitly opted into wipe-whole-disk or picked a
// whole-disk Drive (where wiping the entire device is the only sensible
// thing).
func (o MountOptions) WipeTarget() string {
if o.FormatAs == FormatKeep {
return ""
}
if o.Drive.Type == "disk" {
// Whole-disk Drive — wipe self.
return o.Drive.Path
}
if o.WipeWholeDisk && o.Drive.ParentDisk != "" {
return "/dev/" + o.Drive.ParentDisk
}
return ""
}
// MountPlan builds the ordered list of commands needed to attach `opts.Drive`
// as a media drive at /media/<user>/<label>:
//
// 0. Optionally mkfs.<choice> the drive (destructive — guarded by the TUI's
// final confirm modal)
// 1. mkdir the mount point
// 2. Back up /etc/fstab
// 3. Append a UUID-based line to /etc/fstab — UUID read at runtime so it
// reflects the post-format UUID if we just reformatted
// 4. daemon-reload + mount -a
// 5. ACL: grant the `jellyfin` user read on the tree (if user exists)
// 6. Create the Jellyfin library starter folders (StarterFolders)
func MountPlan(opts MountOptions) ([]step.Step, error) {
if opts.Drive.Path == "" {
return nil, fmt.Errorf("MountPlan: no drive selected")
}
if opts.Label == "" {
return nil, fmt.Errorf("MountPlan: empty label")
}
if opts.User == "" {
return nil, fmt.Errorf("MountPlan: no target user (SUDO_USER)")
}
mountPoint := filepath.Join("/media", opts.User, opts.Label)
effFS := opts.EffectiveFSType()
target := opts.EffectiveTargetDevice()
var steps []step.Step
// -2. Make sure the userspace tools we're about to invoke actually exist.
// A minimal Arch install doesn't ship mkfs.btrfs / mkfs.xfs / sgdisk /
// partprobe / setfacl — installing them first turns a confusing
// "command not found" mid-flow into a clean pacman run.
wipingThisRun := opts.WipeTarget() != ""
if prereq, ok := PrereqStep(opts.FormatAs, wipingThisRun); ok {
steps = append(steps, prereq)
}
// -1. Whole-disk wipe (optional, very destructive). Only meaningful when
// we're also formatting — keeping the existing FS doesn't pair with
// "erase the disk". The TUI's confirm screen lists every other
// partition that will be erased so this is informed consent.
if parentDev := opts.WipeTarget(); parentDev != "" {
steps = append(steps, step.Step{
Title: "Wipe ENTIRE disk " + parentDev,
Cmd: bashAsRoot(fmt.Sprintf(`
set -e
DEV=%s
echo "▸ Preparing $DEV for a whole-disk wipe …"
# Unmount any of its partitions that happen to still be mounted.
for p in $(lsblk -lnpo NAME "$DEV" | tail -n +2); do
mp="$(findmnt -no TARGET "$p" 2>/dev/null || true)"
if [ -n "$mp" ]; then
echo " umount $p (was at $mp)"
umount -f "$p" || true
fi
done
echo " wipefs -a -f $DEV"
wipefs -a -f "$DEV"
if command -v sgdisk >/dev/null 2>&1; then
echo " sgdisk --zap-all $DEV"
sgdisk --zap-all "$DEV" >/dev/null 2>&1 || true
fi
if command -v partprobe >/dev/null 2>&1; then
partprobe "$DEV" >/dev/null 2>&1 || true
fi
blockdev --rereadpt "$DEV" >/dev/null 2>&1 || true
udevadm settle >/dev/null 2>&1 || true
`, shellQuote(parentDev))),
})
}
// 0. Format step (optional, destructive). Uses the EFFECTIVE target —
// whole disk if we just wiped it, otherwise the picked partition.
if fmtStep, ok := FormatStep(target, opts.Label, opts.FormatAs); ok {
steps = append(steps, fmtStep)
}
// 1. Mount point.
steps = append(steps, step.Step{
Title: "Create mount point " + mountPoint,
Cmd: exec.Command("install", "-d", "-o", opts.User, "-g", opts.User, "-m", "0755", mountPoint),
})
// 2. fstab backup.
steps = append(steps, step.Step{
Title: "Back up /etc/fstab",
Cmd: bashAsRoot(`cp -a /etc/fstab "/etc/fstab.bak.$(date +%s)"`),
})
// 3. fstab entry — UUID read AT RUNTIME, from the effective target
// (whole disk or partition), so post-format UUID is captured.
uid, gid := resolveUIDGID(opts.User)
steps = append(steps, step.Step{
Title: "Add UUID-based /etc/fstab entry",
Cmd: bashAsRoot(fmt.Sprintf(`
set -e
DEV=%s
MP=%s
FS=%s
LABEL=%s
UID_N=%d
GID_N=%d
UUID="$(blkid -s UUID -o value "$DEV")"
if [ -z "$UUID" ]; then
echo "Couldn't read UUID for $DEV"
exit 1
fi
COMMON="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
case "$FS" in
exfat|ntfs|vfat) OPTS="uid=${UID_N},gid=${GID_N},umask=002,${COMMON}" ;;
*) OPTS="$COMMON" ;;
esac
LINE="UUID=${UUID} ${MP} ${FS} ${OPTS} 0 2"
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
echo "fstab entry: $LINE"
`,
shellQuote(target),
shellQuote(mountPoint),
shellQuote(effFS),
shellQuote(opts.Label),
uid, gid,
)),
})
// 4. Reload + mount.
steps = append(steps, step.Step{
Title: "Reload systemd & mount the new entry",
Cmd: bashAsRoot("systemctl daemon-reload && mount -a"),
})
// ACL grant — only meaningful on filesystems that actually support POSIX
// ACLs. On ntfs/exfat we already used uid=/gid= in the fstab options so
// jellyfin (added to the user's primary group via supplementary group, if
// configured) will see the right perms; skip setfacl there.
switch effFS {
case "ext4", "btrfs", "xfs":
steps = append(steps, aclGrantStep(mountPoint, opts.RecursiveACL))
}
if opts.SeedStarterFolders {
steps = append(steps, starterFoldersStep(opts.User, mountPoint))
}
return steps, nil
}
// aclGrantStep returns the step that grants the jellyfin service account read
// access to a mounted media tree. Shared by the Add-drive and pool-import flows
// so the ACL logic can't drift between them.
//
// With recursive=false it grants the mount root plus a default ACL (so files
// created LATER inherit access) — correct for a freshly-formatted/empty drive.
// With recursive=true it additionally walks the existing tree with `setfacl -R`
// so media already on a kept drive becomes readable even if it was copied with
// tight permissions; this is the only correct choice when content is present.
func aclGrantStep(mountPoint string, recursive bool) step.Step {
title := "Grant the 'jellyfin' user read access (POSIX ACL)"
recurse := ""
if recursive {
title = "Grant the 'jellyfin' user read access to existing files (recursive ACL)"
// rX = read on files, traverse on dirs only — won't make plain files
// executable. Run before the default-ACL line so a partial failure on
// one odd file still leaves the bulk granted.
recurse = " echo \" applying read access across existing files (may take a moment on a large library)…\"\n" +
" setfacl -R -m u:jellyfin:rX " + shellQuote(mountPoint) + " || true\n"
}
return step.Step{
Title: title,
Cmd: bashAsRoot(fmt.Sprintf(`
if id -u jellyfin >/dev/null 2>&1; then
%[2]s setfacl -m u:jellyfin:rx %[1]s || true
setfacl -d -m u:jellyfin:rx %[1]s || true
else
echo " (jellyfin user not present yet — re-run after installing Jellyfin)"
fi
`, shellQuote(mountPoint), recurse)),
}
}
// StarterFolders are the library directories seeded on a new media drive when
// the user opts in. Names match Jellyfin's content types exactly, so each maps
// straight onto a library you add in the Jellyfin web UI (Movies → Movies
// library, Shows → Shows library, and so on).
var StarterFolders = []string{
"Movies", "Shows", "Music", "Books", "Home Videos", "Music Videos",
}
// starterFoldersStep builds the step that creates StarterFolders under
// <mountPoint>/JellyfinMedia/, owned by `user` and readable by the jellyfin
// service account. Shared by single-drive (MountPlan) and pool (PoolPlan) so
// the two paths can never drift apart. Each folder name is shell-quoted so
// multi-word entries ("Home Videos") survive word-splitting in the for-loop.
func starterFoldersStep(user, mountPoint string) step.Step {
quoted := make([]string, len(StarterFolders))
for i, f := range StarterFolders {
quoted[i] = shellQuote(f)
}
return step.Step{
Title: "Create starter folders (" + strings.Join(StarterFolders, ", ") + ")",
Cmd: bashAsRoot(fmt.Sprintf(`
for d in %[3]s; do
install -d -o %[1]s -g %[1]s -m 0755 %[2]s/JellyfinMedia/"$d"
done
if id -u jellyfin >/dev/null 2>&1; then
setfacl -R -m u:jellyfin:rx %[2]s/JellyfinMedia 2>/dev/null || true
setfacl -R -d -m u:jellyfin:rx %[2]s/JellyfinMedia 2>/dev/null || true
fi
`, user, shellQuote(mountPoint), strings.Join(quoted, " "))),
}
}
// InspectMount looks at what's already on `path` (assumed to be the freshly-
// mounted media drive) and returns a human-readable summary the confirm view
// can show before the user commits to anything.
func InspectMount(path string) (string, bool, error) {
out, err := exec.Command("bash", "-c", fmt.Sprintf(`
shopt -s nullglob dotglob
dirs=()
files=()
total=0
for e in %s/*; do
base="$(basename "$e")"
[ "$base" = "lost+found" ] && continue
case "$base" in .*) continue;; esac
if [ -d "$e" ]; then dirs+=("$base"); else files+=("$base"); fi
total=$((total+1))
done
echo "DIRS:${dirs[*]}"
echo "FILES:${files[*]}"
echo "TOTAL:$total"
echo "SIZE:$(df -h --output=used %s 2>/dev/null | tail -n1 | tr -d ' ')"
`, shellQuote(path), shellQuote(path))).Output()
if err != nil {
return "", false, err
}
lines := strings.Split(string(out), "\n")
var dirs, files []string
var size string
total := 0
for _, l := range lines {
switch {
case strings.HasPrefix(l, "DIRS:"):
dirs = splitSpaceFields(strings.TrimPrefix(l, "DIRS:"))
case strings.HasPrefix(l, "FILES:"):
files = splitSpaceFields(strings.TrimPrefix(l, "FILES:"))
case strings.HasPrefix(l, "TOTAL:"):
fmt.Sscanf(strings.TrimPrefix(l, "TOTAL:"), "%d", &total)
case strings.HasPrefix(l, "SIZE:"):
size = strings.TrimPrefix(l, "SIZE:")
}
}
if total == 0 {
return "Empty.", false, nil
}
var b strings.Builder
fmt.Fprintf(&b, "Has files (≈%s of data).\n", size)
if len(dirs) > 0 {
max := 8
if len(dirs) < max {
max = len(dirs)
}
fmt.Fprintf(&b, " Top-level folders: %s", strings.Join(dirs[:max], ", "))
if len(dirs) > max {
fmt.Fprintf(&b, " … +%d more", len(dirs)-max)
}
b.WriteString("\n")
}
if len(files) > 0 {
max := 5
if len(files) < max {
max = len(files)
}
fmt.Fprintf(&b, " Loose files: %s", strings.Join(files[:max], ", "))
if len(files) > max {
fmt.Fprintf(&b, " … +%d more", len(files)-max)
}
b.WriteString("\n")
}
b.WriteString("They'll be kept as-is.")
return b.String(), true, nil
}
// InspectDevice temporarily mounts `dev` read-only, summarises its top-level
// contents via InspectMount, then unmounts and removes the scratch mountpoint.
//
// It exists because the Add-drive confirm screen needs to show what's already
// on a "Keep existing filesystem" drive, but at confirm time the drive is not
// mounted anywhere. A read-only mount means we can never damage the data we're
// reassuring the user about.
//
// Best-effort by design: any failure (unsupported FS, missing driver, busy
// device) returns a soft, human-readable message and hasFiles=false rather
// than an error, so the confirm flow is never blocked by inspection. A 20s
// context timeout guards against a wedged mount hanging the UI.
func InspectDevice(dev string) (summary string, hasFiles bool) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
tmpOut, err := exec.CommandContext(ctx, "mktemp", "-d", "/tmp/tuistream-inspect.XXXXXX").Output()
if err != nil {
return "Couldn't create a scratch mountpoint to inspect — files will be kept untouched.", false
}
mp := strings.TrimSpace(string(tmpOut))
defer exec.Command("rmdir", mp).Run()
if err := exec.CommandContext(ctx, "mount", "-o", "ro", dev, mp).Run(); err != nil {
return "Couldn't read this drive read-only to preview it — files will be kept untouched.", false
}
defer exec.Command("umount", mp).Run()
s, has, err := InspectMount(mp)
if err != nil {
return "Couldn't list this drive's contents — files will be kept untouched.", false
}
return s, has
}
// ---- helpers ----
func bashAsRoot(script string) *exec.Cmd {
return exec.Command("bash", "-lc", script)
}
func resolveUIDGID(user string) (int, int) {
out, err := exec.Command("getent", "passwd", user).Output()
if err != nil {
return 1000, 1000
}
fields := strings.Split(strings.TrimSpace(string(out)), ":")
if len(fields) < 4 {
return 1000, 1000
}
var uid, gid int
fmt.Sscanf(fields[2], "%d", &uid)
fmt.Sscanf(fields[3], "%d", &gid)
return uid, gid
}
// shellQuote produces a POSIX-shell-safe single-quoted token.
func shellQuote(s string) string {
if s == "" {
return "''"
}
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '.' || r == '_' || r == '-' || r == '/' || r == ':' || r == '=' || r == '@' || r == '+' || r == ',') {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
}
return s
}
func splitSpaceFields(s string) []string {
sc := bufio.NewScanner(strings.NewReader(s))
sc.Buffer(make([]byte, 64*1024), 1<<20)
sc.Split(bufio.ScanWords)
var out []string
for sc.Scan() {
out = append(out, sc.Text())
}
return out
}

301
internal/drives/pool.go Normal file
View file

@ -0,0 +1,301 @@
package drives
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
"tuistream/internal/step"
)
// RAIDLevel is the btrfs data-profile we pass to `mkfs.btrfs -d`. We always
// pair this with a safe metadata profile (raid1 for raid0/raid5, raid10 for
// raid10) because btrfs metadata corruption is much worse than data
// corruption — the filesystem itself becomes unreadable.
type RAIDLevel string
const (
RAID0 RAIDLevel = "raid0"
RAID1 RAIDLevel = "raid1"
RAID5 RAIDLevel = "raid5"
RAID10 RAIDLevel = "raid10"
)
// MinDrives reports the smallest sensible drive count for a given level.
// (mkfs.btrfs will let you create raid5 with 2 drives but it's degenerate.)
func (r RAIDLevel) MinDrives() int {
switch r {
case RAID0, RAID1:
return 2
case RAID5:
return 3
case RAID10:
return 4
}
return 2
}
// MetadataProfile returns the safe metadata profile for this RAID level.
// btrfs metadata corruption breaks the whole filesystem, so we don't follow
// the data profile blindly — raid5 metadata is unsafe (write-hole), raid0
// metadata loses the filesystem on a single-disk failure.
func (r RAIDLevel) MetadataProfile() string {
switch r {
case RAID0, RAID5:
return "raid1" // safe choice independent of data profile
case RAID10:
return "raid10"
}
return string(r)
}
// Tolerates returns "tolerates N drive failure(s)" or "no redundancy".
func (r RAIDLevel) Tolerates() string {
switch r {
case RAID0:
return "no redundancy — one failed drive loses the whole pool"
case RAID1:
return "any single drive can fail"
case RAID5:
return "any single drive can fail"
case RAID10:
return "one drive in each mirror can fail"
}
return ""
}
// PoolOptions captures the user's choices for creating a multi-drive pool.
type PoolOptions struct {
Drives []Drive
Level RAIDLevel
Label string // friendly directory name under /media/<user>/
User string
// WipeWholeDisks: when true, every selected partition's PARENT DISK is
// zapped (sgdisk --zap-all + wipefs -af) before mkfs, and mkfs.btrfs is
// given the whole-disk path (/dev/sda) instead of the partition
// (/dev/sda1). This is the right thing for RAID because it kills every
// stray FS/MD/LVM signature that would otherwise confuse btrfs about
// which device belongs to the pool. It also destroys any OTHER
// partitions on those disks — the confirm screen must show this.
WipeWholeDisks bool
}
// TargetDevices returns the actual block-device paths mkfs.btrfs will be
// called with, given WipeWholeDisks. With WipeWholeDisks=true these are the
// unique parent disks (each listed once); with =false they are the chosen
// partitions verbatim.
func (o PoolOptions) TargetDevices() []string {
if !o.WipeWholeDisks {
out := make([]string, len(o.Drives))
for i, d := range o.Drives {
out[i] = d.Path
}
return out
}
seen := map[string]bool{}
var out []string
for _, d := range o.Drives {
parent := d.ParentDisk
if parent == "" {
// Already a whole disk — use as-is.
out = append(out, d.Path)
continue
}
p := "/dev/" + parent
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
return out
}
// PoolPlan builds the ordered list of commands to wipe the selected drives
// and create a single btrfs filesystem spanning all of them at the chosen
// RAID level. Format is always destructive (RAID setup requires fresh disks).
func PoolPlan(opts PoolOptions) ([]step.Step, error) {
if len(opts.Drives) < opts.Level.MinDrives() {
return nil, fmt.Errorf("RAID %s needs at least %d drives (got %d)",
opts.Level, opts.Level.MinDrives(), len(opts.Drives))
}
if opts.Label == "" {
return nil, fmt.Errorf("PoolPlan: empty label")
}
if opts.User == "" {
return nil, fmt.Errorf("PoolPlan: no target user")
}
mountPoint := filepath.Join("/media", opts.User, opts.Label)
devs := opts.TargetDevices()
devsQuoted := make([]string, len(devs))
for i, d := range devs {
devsQuoted[i] = shellQuote(d)
}
devList := strings.Join(devsQuoted, " ")
var steps []step.Step
// 0. Prereqs: btrfs-progs, gptfdisk, parted, acl. Minimal Arch boxes
// don't ship these.
if prereq, ok := PrereqStepBtrfsPool(); ok {
steps = append(steps, prereq)
}
// 1. Wipe existing signatures. For partition-level we just wipefs; for
// whole-disk we additionally sgdisk-zap and partprobe so btrfs can't
// later get confused by leftover GPT backups or per-partition magic.
wipeTitle := fmt.Sprintf("Wipe signatures from %d partition(s)", len(devs))
wipeBody := fmt.Sprintf(`
set -e
for d in %s; do
# Unmount the partition if it's still mounted, else mkfs.btrfs refuses it.
mp="$(findmnt -no TARGET "$d" 2>/dev/null | head -n1 || true)"
if [ -n "$mp" ]; then
echo " umount $d (was at $mp)"
umount -R -f "$d" 2>/dev/null || umount -l "$d" 2>/dev/null || true
fi
echo "▸ wipefs -a -f $d"
wipefs -a -f "$d"
done
`, devList)
if opts.WipeWholeDisks {
wipeTitle = fmt.Sprintf("Wipe ENTIRE parent disk(s) — %d device(s)", len(devs))
wipeBody = fmt.Sprintf(`
set -e
for d in %s; do
echo "▸ Preparing $d for RAID …"
# Unmount the disk itself (a whole-disk fs, e.g. a btrfs pool from a previous
# run) AND any of its partitions that happen to still be mounted. The disk
# line must be included without it, re-running over an already-mounted
# whole-disk pool fails mkfs.btrfs with "ERROR: $d is mounted".
for p in $(lsblk -lnpo NAME "$d"); do
mp="$(findmnt -no TARGET "$p" 2>/dev/null | head -n1 || true)"
if [ -n "$mp" ]; then
echo " umount $p (was at $mp)"
umount -R -f "$p" 2>/dev/null || umount -l "$p" 2>/dev/null || true
fi
done
# Belt-and-braces wipe: util-linux wipefs nukes the signatures it knows,
# sgdisk --zap-all (from gptfdisk, optional) nukes GPT + protective MBR
# including the backup at the disk's tail, and partprobe forces the kernel
# to drop its cached partition table.
echo " wipefs -a -f $d"
wipefs -a -f "$d"
if command -v sgdisk >/dev/null 2>&1; then
echo " sgdisk --zap-all $d"
sgdisk --zap-all "$d" >/dev/null 2>&1 || true
fi
if command -v partprobe >/dev/null 2>&1; then
partprobe "$d" >/dev/null 2>&1 || true
fi
blockdev --rereadpt "$d" >/dev/null 2>&1 || true
udevadm settle >/dev/null 2>&1 || true
done
`, devList)
}
steps = append(steps, step.Step{
Title: wipeTitle,
Cmd: bashAsRoot(wipeBody),
})
// 2. Create the btrfs pool. -f overwrites any remaining FS magic.
mkfsArgs := []string{
"-f",
"-L", opts.Label,
"-d", string(opts.Level),
"-m", opts.Level.MetadataProfile(),
}
mkfsArgs = append(mkfsArgs, devs...)
scope := "partition"
if opts.WipeWholeDisks {
scope = "whole-disk"
}
steps = append(steps, step.Step{
Title: fmt.Sprintf("Create btrfs %s pool (label=%q, %d %s device(s))",
opts.Level, opts.Label, len(devs), scope),
Cmd: exec.Command("mkfs.btrfs", mkfsArgs...),
})
// 3. Mount point.
steps = append(steps, step.Step{
Title: "Create mount point " + mountPoint,
Cmd: exec.Command("install", "-d", "-o", opts.User, "-g", opts.User, "-m", "0755", mountPoint),
})
// 4. fstab backup.
steps = append(steps, step.Step{
Title: "Back up /etc/fstab",
Cmd: bashAsRoot(`cp -a /etc/fstab "/etc/fstab.bak.$(date +%s)"`),
})
// 5. fstab entry — btrfs lets us mount any constituent device and have
// it auto-resolve the rest. We use the first device's post-mkfs UUID
// (could be either /dev/sda or /dev/sda1 depending on WipeWholeDisks).
steps = append(steps, step.Step{
Title: "Add UUID-based /etc/fstab entry for the pool",
Cmd: bashAsRoot(fmt.Sprintf(`
set -e
DEV=%s
MP=%s
LABEL=%s
UUID="$(blkid -s UUID -o value "$DEV")"
if [ -z "$UUID" ]; then
echo "Couldn't read UUID for $DEV after mkfs.btrfs — aborting"
exit 1
fi
# btrfs mounts via any constituent device. nofail so a missing drive at boot
# doesn't drop the box into emergency mode we'd rather start without it.
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
echo "fstab: $LINE"
`,
shellQuote(devs[0]),
shellQuote(mountPoint),
shellQuote(opts.Label),
)),
})
// 6. Mount.
steps = append(steps, step.Step{
Title: "Reload systemd & mount the pool",
Cmd: bashAsRoot("systemctl daemon-reload && mount -a"),
})
// 7. ACL (btrfs supports POSIX ACLs unconditionally).
steps = append(steps, step.Step{
Title: "Grant the 'jellyfin' user read access (POSIX ACL)",
Cmd: bashAsRoot(fmt.Sprintf(`
if id -u jellyfin >/dev/null 2>&1; then
setfacl -m u:jellyfin:rx %[1]s || true
setfacl -d -m u:jellyfin:rx %[1]s || true
else
echo " (jellyfin user not present yet — re-run after installing Jellyfin)"
fi
`, shellQuote(mountPoint))),
})
// 8. Starter folders.
steps = append(steps, starterFoldersStep(opts.User, mountPoint))
return steps, nil
}
// AvailableLevels returns the RAID levels valid for `n` drives, in the order
// we want to show them in the UI (RAID 1 first since it's the safest).
func AvailableLevels(n int) []RAIDLevel {
all := []RAIDLevel{RAID1, RAID0, RAID5, RAID10}
var out []RAIDLevel
for _, l := range all {
if l.MinDrives() <= n {
out = append(out, l)
}
}
return out
}

View file

@ -0,0 +1,193 @@
package drives
import (
"fmt"
"os/exec"
"path/filepath"
"tuistream/internal/step"
)
// DetachedPool is a multi-device btrfs filesystem whose member devices are all
// present but none is currently mounted — a pool left over from a previous
// setup that can be re-attached WITHOUT reformatting.
//
// The classifier marks these devices RolePoolMember and (rightly) refuses to
// offer them for the destructive Add-drive flow, because wiping any one member
// destroys the whole pool. Import is the safe counterpart: it mounts the pool
// as-is. Single-device btrfs drives are NOT pools — they're handled by the
// normal Add-drive "Keep existing filesystem" path.
type DetachedPool struct {
UUID string // shared across every member device
Label string // btrfs label, "" if unlabelled
Members []Drive // every device carrying this UUID, in inventory order
}
// FirstDevice returns the device path to mount the pool via; btrfs resolves the
// remaining members from the kernel's device scan. "" if the pool has no
// members (shouldn't happen for a value returned by DetachedPools).
func (p DetachedPool) FirstDevice() string {
if len(p.Members) == 0 {
return ""
}
return p.Members[0].Path
}
// DisplayLabel returns the label or a placeholder for the UI.
func (p DetachedPool) DisplayLabel() string {
if p.Label != "" {
return p.Label
}
return "(unlabelled)"
}
// DetachedPools groups the inventory's btrfs devices by UUID and returns those
// that look like an importable detached pool: 2+ member devices, none mounted,
// and none living on a system disk. These are exactly the devices the role
// classifier flags as "btrfs pool … (multi-device, detached)".
func (inv *Inventory) DetachedPools() []DetachedPool {
type group struct {
label string
members []Drive
mounted bool
onSys bool
}
var order []string
groups := map[string]*group{}
for _, d := range inv.All {
if d.FSType != "btrfs" || d.UUID == "" {
continue
}
g := groups[d.UUID]
if g == nil {
g = &group{}
groups[d.UUID] = g
order = append(order, d.UUID)
}
g.members = append(g.members, d)
if d.Label != "" {
g.label = d.Label
}
if d.MountPoint != "" {
g.mounted = true
}
if inv.SystemDisks[d.Name] || inv.SystemDisks[d.ParentDisk] {
g.onSys = true
}
}
var out []DetachedPool
for _, u := range order {
g := groups[u]
// 2+ members → genuinely a multi-device pool (single-device btrfs is the
// Add-drive Keep path). All unmounted → "detached". Not on a system disk.
if len(g.members) < 2 || g.mounted || g.onSys {
continue
}
out = append(out, DetachedPool{UUID: u, Label: g.label, Members: g.members})
}
return out
}
// ImportPoolOptions captures an import-existing-pool run.
type ImportPoolOptions struct {
Pool DetachedPool
Label string // friendly directory name under /media/<user>/
User string
// SeedStarterFolders creates the Jellyfin library folders under
// <mount>/JellyfinMedia/. Left off when the pool already holds content so
// we don't clutter an existing library layout.
SeedStarterFolders bool
// RecursiveACL grants jellyfin read access over the whole existing tree
// (setfacl -R), not just the mount root. Set when the pool already holds
// media so pre-existing files copied with tight permissions stay readable.
RecursiveACL bool
}
// ImportPoolPlan builds the ordered, NON-DESTRUCTIVE commands to re-attach an
// existing btrfs pool at /media/<user>/<label>:
//
// 0. Ensure btrfs-progs + acl are present
// 1. mkdir the mount point
// 2. Back up /etc/fstab
// 3. Append a UUID-based fstab line (btrfs assembles all members from the UUID)
// 4. daemon-reload + btrfs device scan + mount -a
// 5. ACL: grant the jellyfin user read (if the user exists)
// 6. Optionally seed the Jellyfin starter folders
//
// Nothing here formats, wipes, or partitions — the pool's data is untouched.
func ImportPoolPlan(opts ImportPoolOptions) ([]step.Step, error) {
dev := opts.Pool.FirstDevice()
if dev == "" {
return nil, fmt.Errorf("ImportPoolPlan: pool has no member devices")
}
if opts.Label == "" {
return nil, fmt.Errorf("ImportPoolPlan: empty label")
}
if opts.User == "" {
return nil, fmt.Errorf("ImportPoolPlan: no target user (SUDO_USER)")
}
mountPoint := filepath.Join("/media", opts.User, opts.Label)
var steps []step.Step
if prereq, ok := PrereqStepImportPool(); ok {
steps = append(steps, prereq)
}
steps = append(steps, step.Step{
Title: "Create mount point " + mountPoint,
Cmd: exec.Command("install", "-d", "-o", opts.User, "-g", opts.User, "-m", "0755", mountPoint),
})
steps = append(steps, step.Step{
Title: "Back up /etc/fstab",
Cmd: bashAsRoot(`cp -a /etc/fstab "/etc/fstab.bak.$(date +%s)"`),
})
steps = append(steps, step.Step{
Title: "Add UUID-based /etc/fstab entry for the pool",
Cmd: bashAsRoot(fmt.Sprintf(`
set -e
DEV=%s
MP=%s
LABEL=%s
# Make sure the kernel knows every member device before we read the UUID,
# otherwise a cold pool can read back as not-yet-assembled.
btrfs device scan >/dev/null 2>&1 || true
UUID="$(blkid -s UUID -o value "$DEV")"
if [ -z "$UUID" ]; then
echo "Couldn't read UUID for $DEV"
exit 1
fi
# btrfs mounts via any constituent device. nofail so a missing drive at boot
# doesn't drop the box into emergency mode.
OPTS="defaults,nofail,x-gvfs-show,x-gvfs-name=$LABEL"
LINE="UUID=${UUID} ${MP} btrfs ${OPTS} 0 0"
grep -qF -- "$LINE" /etc/fstab || printf '%%s\n' "$LINE" >> /etc/fstab
echo "fstab: $LINE"
`,
shellQuote(dev),
shellQuote(mountPoint),
shellQuote(opts.Label),
)),
})
steps = append(steps, step.Step{
Title: "Reload systemd & mount the pool",
Cmd: bashAsRoot("systemctl daemon-reload; btrfs device scan >/dev/null 2>&1 || true; mount -a"),
})
steps = append(steps, aclGrantStep(mountPoint, opts.RecursiveACL))
if opts.SeedStarterFolders {
steps = append(steps, starterFoldersStep(opts.User, mountPoint))
}
return steps, nil
}

View file

@ -0,0 +1,82 @@
package drives
import (
"os/exec"
"strings"
"tuistream/internal/step"
)
// PrereqStep returns an idempotent `pacman -S --needed --noconfirm` step
// covering all the userspace tools the mount / format / wipe / ACL flow
// will actually invoke. Returns ok=false when nothing extra is needed.
//
// Arch's `base` group already ships: util-linux (wipefs, lsblk, blkid,
// mount, findmnt), e2fsprogs (mkfs.ext4), coreutils. Things that are NOT
// in base, and that we may rely on:
//
// btrfs-progs → mkfs.btrfs
// xfsprogs → mkfs.xfs
// gptfdisk → sgdisk (whole-disk wipe)
// parted → partprobe (whole-disk wipe)
// acl → setfacl (jellyfin read access)
// dosfstools → mkfs.vfat / fsck.vfat — not used by us, skipped
func PrereqStep(formatAs FormatChoice, wipingWholeDisk bool) (step.Step, bool) {
pkgs := requiredPackages(formatAs, wipingWholeDisk)
if len(pkgs) == 0 {
return step.Step{}, false
}
args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...)
return step.Step{
Title: "Install required tools: " + strings.Join(pkgs, ", "),
Cmd: exec.Command("pacman", args...),
}, true
}
// PrereqStepBtrfsPool is the prereq step for a btrfs RAID pool: btrfs-progs
// is non-negotiable, gptfdisk + parted are highly desirable for whole-disk
// wipe, acl for jellyfin grant.
func PrereqStepBtrfsPool() (step.Step, bool) {
return PrereqStep(FormatBtrfs, true)
}
// PrereqStepImportPool is the prereq step for importing an EXISTING btrfs pool:
// btrfs-progs (mount.btrfs + `btrfs device scan`) and acl (jellyfin grant).
// No mkfs/wipe tools — import is non-destructive — so we don't pull gptfdisk
// or parted. FormatBtrfs with wipingWholeDisk=false yields exactly acl +
// btrfs-progs.
func PrereqStepImportPool() (step.Step, bool) {
return PrereqStep(FormatBtrfs, false)
}
func requiredPackages(formatAs FormatChoice, wipingWholeDisk bool) []string {
seen := map[string]bool{}
var pkgs []string
add := func(name string) {
if seen[name] {
return
}
seen[name] = true
pkgs = append(pkgs, name)
}
// ACL is always needed — setfacl grants jellyfin read access on every
// flow that ends with the drive mounted under /media/<user>/.
add("acl")
switch formatAs {
case FormatBtrfs:
add("btrfs-progs")
case FormatXFS:
add("xfsprogs")
case FormatExt4:
// e2fsprogs is in the `base` group on Arch, always present.
}
if wipingWholeDisk {
add("gptfdisk") // sgdisk
add("parted") // partprobe
}
return pkgs
}

View file

@ -0,0 +1,84 @@
// Package firewall opens/closes the Jellyfin LAN ports via UFW if present.
//
// We deliberately target UFW only — Omarchy & Arch installs commonly have it.
// If UFW isn't installed, the helpers return a no-op plan with a clear note
// in the title so the TUI surfaces "skipped" instead of silently doing nothing.
package firewall
import (
"os/exec"
"strings"
"tuistream/internal/step"
)
const (
WebPort = "8096/tcp" // HTTP web UI / API
DiscoveryPort = "7359/udp" // Jellyfin client auto-discovery on the LAN
)
// Available reports whether UFW is installed (otherwise plans return no-ops).
func Available() bool {
_, err := exec.LookPath("ufw")
return err == nil
}
// State is a snapshot of which Jellyfin ports UFW is currently allowing.
type State struct {
UFWInstalled bool
WebOpen bool
DiscoveryOpen bool
}
func LoadState() State {
s := State{UFWInstalled: Available()}
if !s.UFWInstalled {
return s
}
out, err := exec.Command("ufw", "status").Output()
if err != nil {
return s
}
text := string(out)
s.WebOpen = containsAllow(text, WebPort)
s.DiscoveryOpen = containsAllow(text, DiscoveryPort)
return s
}
// AllOpen returns true if both Jellyfin ports are currently allowed.
func (s State) AllOpen() bool { return s.WebOpen && s.DiscoveryOpen }
// AnyOpen returns true if at least one Jellyfin port is allowed (used to
// decide whether the toggle's verb should be "close" or "open").
func (s State) AnyOpen() bool { return s.WebOpen || s.DiscoveryOpen }
func containsAllow(ufwStatus, port string) bool {
// `ufw status` lines look like: "8096/tcp ALLOW Anywhere"
for _, line := range strings.Split(ufwStatus, "\n") {
if strings.Contains(line, port) && strings.Contains(line, "ALLOW") {
return true
}
}
return false
}
// OpenPlan returns the steps needed to allow Jellyfin's two LAN ports.
func OpenPlan() []step.Step {
if !Available() {
return []step.Step{{Title: "UFW not installed — skipping firewall", Cmd: exec.Command("true")}}
}
return []step.Step{
{Title: "ufw allow " + WebPort, Cmd: exec.Command("ufw", "allow", WebPort)},
{Title: "ufw allow " + DiscoveryPort, Cmd: exec.Command("ufw", "allow", DiscoveryPort)},
}
}
// ClosePlan returns the steps needed to remove the two Jellyfin allow rules.
func ClosePlan() []step.Step {
if !Available() {
return []step.Step{{Title: "UFW not installed — nothing to close", Cmd: exec.Command("true")}}
}
return []step.Step{
{Title: "ufw delete allow " + WebPort, Cmd: exec.Command("ufw", "delete", "allow", WebPort)},
{Title: "ufw delete allow " + DiscoveryPort, Cmd: exec.Command("ufw", "delete", "allow", DiscoveryPort)},
}
}

139
internal/health/cpu.go Normal file
View file

@ -0,0 +1,139 @@
// CPU sampling for the Monitor tab. /proc/stat reports cumulative ticks
// per CPU since boot; usage % is the delta of (total - idle) over the
// delta of total between two snapshots.
//
// The Monitor tab samples once per second, holds the previous reading on
// the model, and renders the delta — first sample after startup shows 0
// because there's nothing to subtract against yet.
package health
import (
"bufio"
"os"
"strconv"
"strings"
)
// ProcStatLine is one row of /proc/stat (either the "cpu" aggregate or a
// single "cpuN" core). All values are cumulative kernel ticks since boot.
type ProcStatLine struct {
Index int // -1 for the aggregate line
User uint64
Nice uint64
System uint64
Idle uint64
IOWait uint64
IRQ uint64
SoftIRQ uint64
Steal uint64
Total uint64 // sum of all of the above
IdleAll uint64 // Idle + IOWait
}
// CPUUsage is the per-tick result: an aggregate % and per-core %s in
// CPU-index order. Empty when we don't yet have a previous sample.
type CPUUsage struct {
Aggregate float64
Cores []float64
}
// ReadProcStat parses /proc/stat. Stops at the first non-cpu line for
// speed (we don't need intr/ctxt/btime/etc).
func ReadProcStat() ([]ProcStatLine, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return nil, err
}
defer f.Close()
var out []ProcStatLine
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "cpu") {
break
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
idx := -1
if fields[0] != "cpu" {
if n, err := strconv.Atoi(strings.TrimPrefix(fields[0], "cpu")); err == nil {
idx = n
}
}
var ps ProcStatLine
ps.Index = idx
vals := fields[1:]
readU64 := func(i int) uint64 {
if i >= len(vals) {
return 0
}
v, _ := strconv.ParseUint(vals[i], 10, 64)
return v
}
ps.User = readU64(0)
ps.Nice = readU64(1)
ps.System = readU64(2)
ps.Idle = readU64(3)
ps.IOWait = readU64(4)
ps.IRQ = readU64(5)
ps.SoftIRQ = readU64(6)
ps.Steal = readU64(7)
for _, v := range vals {
n, _ := strconv.ParseUint(v, 10, 64)
ps.Total += n
}
ps.IdleAll = ps.Idle + ps.IOWait
out = append(out, ps)
}
return out, sc.Err()
}
// DeltaCPU computes usage between two /proc/stat snapshots. Returns an
// empty CPUUsage if `prev` is empty (first sample after startup).
func DeltaCPU(prev, now []ProcStatLine) CPUUsage {
if len(prev) == 0 {
return CPUUsage{}
}
prevByIdx := map[int]ProcStatLine{}
for _, p := range prev {
prevByIdx[p.Index] = p
}
var u CPUUsage
for _, n := range now {
p, ok := prevByIdx[n.Index]
if !ok {
continue
}
pct := cpuPct(p, n)
if n.Index == -1 {
u.Aggregate = pct
} else {
u.Cores = append(u.Cores, pct)
}
}
return u
}
func cpuPct(prev, now ProcStatLine) float64 {
if now.Total <= prev.Total {
return 0
}
dTotal := now.Total - prev.Total
var dIdle uint64
if now.IdleAll > prev.IdleAll {
dIdle = now.IdleAll - prev.IdleAll
}
if dTotal == 0 {
return 0
}
pct := float64(dTotal-dIdle) / float64(dTotal) * 100
if pct < 0 {
return 0
}
if pct > 100 {
return 100
}
return pct
}

474
internal/health/health.go Normal file
View file

@ -0,0 +1,474 @@
// Package health gathers a system snapshot for the Monitor tab: drive
// capacities, btrfs RAID error counters, SMART health per physical disk,
// CPU + memory load, and Jellyfin service state.
//
// Every probe is best-effort: if smartctl can't read a USB bridge, or
// btrfs isn't installed yet, or /proc/meminfo changes shape on us, the
// rest of the snapshot still renders. Errors are stuffed into the
// per-row Note field so the UI can show them inline.
package health
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
// probeTimeout caps any external health probe. A flaky/half-dead USB bridge
// can make `smartctl` (or `btrfs device stats` on a degraded pool) block
// indefinitely; without a deadline that would freeze the Monitor tab's
// refresh tick. Each probe is best-effort, so on timeout we just report it.
const probeTimeout = 8 * time.Second
// outputWithTimeout runs a command and returns its stdout, killing it (and
// returning a non-nil error) if it doesn't finish within probeTimeout.
func outputWithTimeout(name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).Output()
if ctx.Err() == context.DeadlineExceeded {
return out, ctx.Err()
}
return out, err
}
// Snapshot is one point-in-time view of the host. Re-taken on each tick.
type Snapshot struct {
Time time.Time
Capacities []Capacity
Disks []DiskHealth
Btrfs []BtrfsPool
System SystemHealth
Jellyfin JellyfinHealth
}
// Capacity is a single mounted filesystem we care about.
type Capacity struct {
Mountpoint string
Label string // friendly name shown to user
FSType string
Total uint64
Used uint64
Avail uint64
PctUsed float64
Note string // populated if statfs fails or returns weird values
}
// DiskHealth is one physical disk's SMART summary.
type DiskHealth struct {
Device string // /dev/sda
Model string
Transport string // sata / usb / nvme
SmartReady bool
Passed bool
TempC int
PowerOnHr int
Reallocated int
Pending int
Note string // e.g. "USB bridge not supported", or smartctl errors
}
// BtrfsPool is one btrfs filesystem mounted on the box (we only care
// about ones we manage). Devices is per physical device in the pool.
type BtrfsPool struct {
Mountpoint string
Label string
Devices []BtrfsDevice
Healthy bool
Note string
}
// BtrfsDevice is one device in a btrfs pool with its persistent error
// counters. Any non-zero counter is suspicious.
type BtrfsDevice struct {
DevID int
Path string
WriteErrs int
ReadErrs int
FlushErrs int
CorruptErrs int
GenErrs int
Missing bool
}
// SystemHealth covers CPU/memory/load.
type SystemHealth struct {
LoadAvg1 float64
LoadAvg5 float64
LoadAvg15 float64
CPUCount int
MemTotalKB uint64
MemUsedKB uint64
MemPct float64
UptimeS int64
}
// JellyfinHealth is just the install + service-active flags. Re-uses
// what the Setup tab already tracks but on its own schedule.
type JellyfinHealth struct {
Installed bool
Active bool
ServiceUnit string
}
// Inputs tells Take which mountpoints + disks the host has so we don't
// re-run lsblk inside this package.
type Inputs struct {
Capacities []CapacityTarget
PhysicalDisks []DiskTarget
BtrfsMounts []BtrfsTarget
}
type CapacityTarget struct {
Mountpoint string
Label string
FSType string
}
type DiskTarget struct {
Device string
Model string
Transport string
}
type BtrfsTarget struct {
Mountpoint string
Label string
}
// Take runs every probe and returns the assembled snapshot. Never
// returns an error — failures land in per-row Note fields so the UI can
// degrade gracefully.
func Take(in Inputs) Snapshot {
s := Snapshot{Time: time.Now()}
for _, c := range in.Capacities {
s.Capacities = append(s.Capacities, takeCapacity(c))
}
for _, d := range in.PhysicalDisks {
s.Disks = append(s.Disks, takeDisk(d))
}
for _, b := range in.BtrfsMounts {
s.Btrfs = append(s.Btrfs, takeBtrfs(b))
}
s.System = takeSystem()
s.Jellyfin = takeJellyfin()
return s
}
// ---------- capacity ----------
func takeCapacity(t CapacityTarget) Capacity {
c := Capacity{Mountpoint: t.Mountpoint, Label: t.Label, FSType: t.FSType}
var st syscall.Statfs_t
if err := syscall.Statfs(t.Mountpoint, &st); err != nil {
c.Note = "statfs failed: " + err.Error()
return c
}
bs := uint64(st.Bsize)
c.Total = st.Blocks * bs
c.Avail = st.Bavail * bs
c.Used = c.Total - st.Bfree*bs
if c.Total > 0 {
c.PctUsed = float64(c.Used) / float64(c.Total) * 100
}
return c
}
// ---------- SMART ----------
// smartctlJSON is the subset of `smartctl -j -H -A -i` fields we read.
type smartctlJSON struct {
ModelName string `json:"model_name"`
SmartStatus struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature struct {
Current int `json:"current"`
} `json:"temperature"`
PowerOnTime struct {
Hours int `json:"hours"`
} `json:"power_on_time"`
NVMeLog struct {
PowerOnHours int `json:"power_on_hours"`
} `json:"nvme_smart_health_information_log"`
AtaAttrs struct {
Table []struct {
Name string `json:"name"`
Raw struct {
Value int `json:"value"`
} `json:"raw"`
} `json:"table"`
} `json:"ata_smart_attributes"`
Smartctl struct {
Messages []struct {
Severity string `json:"severity"`
String string `json:"string"`
} `json:"messages"`
ExitStatus int `json:"exit_status"`
} `json:"smartctl"`
}
func takeDisk(t DiskTarget) DiskHealth {
d := DiskHealth{Device: t.Device, Model: t.Model, Transport: t.Transport}
if _, err := exec.LookPath("smartctl"); err != nil {
d.Note = "smartmontools not installed"
return d
}
args := []string{"-j", "-H", "-A", "-i", t.Device}
// USB bridges often need a device-type hint; without it smartctl
// bails. Try sat for USB SATA bridges.
if t.Transport == "usb" {
args = append([]string{"-d", "sat"}, args...)
}
out, err := outputWithTimeout("smartctl", args...)
if err == context.DeadlineExceeded {
d.Note = "smartctl timed out (unresponsive USB bridge?)"
return d
}
if len(out) == 0 {
d.Note = "smartctl produced no output"
return d
}
var parsed smartctlJSON
if err := json.Unmarshal(out, &parsed); err != nil {
d.Note = "couldn't parse smartctl JSON"
return d
}
if parsed.ModelName != "" && d.Model == "" {
d.Model = parsed.ModelName
}
d.SmartReady = parsed.SmartStatus.Passed || parsed.Temperature.Current > 0 || parsed.PowerOnTime.Hours > 0 || parsed.NVMeLog.PowerOnHours > 0
d.Passed = parsed.SmartStatus.Passed
d.TempC = parsed.Temperature.Current
if parsed.PowerOnTime.Hours > 0 {
d.PowerOnHr = parsed.PowerOnTime.Hours
} else {
d.PowerOnHr = parsed.NVMeLog.PowerOnHours
}
for _, attr := range parsed.AtaAttrs.Table {
switch attr.Name {
case "Reallocated_Sector_Ct":
d.Reallocated = attr.Raw.Value
case "Current_Pending_Sector":
d.Pending = attr.Raw.Value
}
}
if !d.SmartReady {
// Pull a useful message out of the smartctl json if it complained.
for _, m := range parsed.Smartctl.Messages {
if m.Severity == "error" || m.Severity == "warning" {
d.Note = strings.TrimSpace(m.String)
break
}
}
if d.Note == "" {
d.Note = "SMART not available (USB bridge?)"
}
}
return d
}
// ---------- btrfs ----------
func takeBtrfs(t BtrfsTarget) BtrfsPool {
p := BtrfsPool{Mountpoint: t.Mountpoint, Label: t.Label, Healthy: true}
if _, err := exec.LookPath("btrfs"); err != nil {
p.Note = "btrfs-progs not installed"
p.Healthy = false
return p
}
// `btrfs device stats <mp>` prints lines like:
// [/dev/sda].write_io_errs 0
out, err := outputWithTimeout("btrfs", "device", "stats", t.Mountpoint)
if err == context.DeadlineExceeded {
p.Note = "btrfs device stats timed out (degraded/missing device?)"
p.Healthy = false
return p
}
if err != nil {
p.Note = "btrfs device stats failed: " + err.Error()
p.Healthy = false
return p
}
devs := map[string]*BtrfsDevice{}
order := []string{}
sc := bufio.NewScanner(strings.NewReader(string(out)))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "[") {
continue
}
end := strings.Index(line, "]")
if end < 0 {
continue
}
path := line[1:end]
rest := strings.TrimSpace(line[end+1:])
// rest looks like "write_io_errs 0" — split into key + val
fields := strings.Fields(rest)
if len(fields) < 2 {
continue
}
key := strings.TrimPrefix(fields[0], ".")
val, _ := strconv.Atoi(fields[len(fields)-1])
d, ok := devs[path]
if !ok {
d = &BtrfsDevice{Path: path}
devs[path] = d
order = append(order, path)
}
switch key {
case "write_io_errs":
d.WriteErrs = val
case "read_io_errs":
d.ReadErrs = val
case "flush_io_errs":
d.FlushErrs = val
case "corruption_errs":
d.CorruptErrs = val
case "generation_errs":
d.GenErrs = val
}
}
for _, path := range order {
d := devs[path]
if !deviceExists(path) {
d.Missing = true
}
if d.WriteErrs+d.ReadErrs+d.FlushErrs+d.CorruptErrs+d.GenErrs > 0 || d.Missing {
p.Healthy = false
}
p.Devices = append(p.Devices, *d)
}
return p
}
func deviceExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ---------- system (cpu + memory + uptime) ----------
func takeSystem() SystemHealth {
s := SystemHealth{CPUCount: runtime.NumCPU()}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
fields := strings.Fields(string(data))
if len(fields) >= 3 {
s.LoadAvg1, _ = strconv.ParseFloat(fields[0], 64)
s.LoadAvg5, _ = strconv.ParseFloat(fields[1], 64)
s.LoadAvg15, _ = strconv.ParseFloat(fields[2], 64)
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
var avail uint64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
val, _ := strconv.ParseUint(fields[1], 10, 64)
switch fields[0] {
case "MemTotal:":
s.MemTotalKB = val
case "MemAvailable:":
avail = val
}
}
if s.MemTotalKB > 0 {
used := s.MemTotalKB - avail
s.MemUsedKB = used
s.MemPct = float64(used) / float64(s.MemTotalKB) * 100
}
}
if data, err := os.ReadFile("/proc/uptime"); err == nil {
fields := strings.Fields(string(data))
if len(fields) > 0 {
if up, err := strconv.ParseFloat(fields[0], 64); err == nil {
s.UptimeS = int64(up)
}
}
}
return s
}
// ---------- jellyfin ----------
func takeJellyfin() JellyfinHealth {
j := JellyfinHealth{ServiceUnit: "jellyfin.service"}
if _, err := exec.LookPath("systemctl"); err != nil {
return j
}
if pacmanHasJellyfin() {
j.Installed = true
}
out, _ := exec.Command("systemctl", "is-active", j.ServiceUnit).Output()
j.Active = strings.TrimSpace(string(out)) == "active"
return j
}
func pacmanHasJellyfin() bool {
if _, err := exec.LookPath("pacman"); err != nil {
return false
}
for _, pkg := range []string{"jellyfin-server", "jellyfin", "jellyfin-bin"} {
err := exec.Command("pacman", "-Qi", pkg).Run()
if err == nil {
return true
}
}
return false
}
// ---------- formatting helpers used by the view ----------
// HumanBytes renders a byte count with one decimal for sub-10 values.
// Reusable across the TUI and tests.
func HumanBytes(n uint64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
tb = 1024 * gb
)
switch {
case n >= tb:
return fmtBytes(float64(n)/tb, "T")
case n >= gb:
return fmtBytes(float64(n)/gb, "G")
case n >= mb:
return fmtBytes(float64(n)/mb, "M")
case n >= kb:
return fmtBytes(float64(n)/kb, "K")
}
return fmt.Sprintf("%dB", n)
}
func fmtBytes(v float64, suffix string) string {
if v < 10 {
return strings.TrimSuffix(fmt.Sprintf("%.1f", v), ".0") + suffix
}
return fmt.Sprintf("%.0f", v) + suffix
}
// HumanUptime renders a seconds count as e.g. "3d 4h", "2h 17m", "44m".
func HumanUptime(s int64) string {
d := s / 86400
h := (s % 86400) / 3600
m := (s % 3600) / 60
switch {
case d > 0:
return fmt.Sprintf("%dd %dh", d, h)
case h > 0:
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}

View file

@ -0,0 +1,75 @@
package jellyfin
import (
"os/exec"
"strings"
"tuistream/internal/step"
)
// InstallPlan builds the ordered list of commands needed to install Jellyfin
// from the official Arch extra repo. We don't support the AUR jellyfin-bin
// path anymore — see reference_aur_jellyfin_bin_stale memory for the why.
//
// This is a *plan*, not an execution. The TUI runs it step by step via
// tea.ExecProcess so pacman can prompt interactively if needed.
func InstallPlan() []step.Step {
return []step.Step{
{
Title: "Refresh package databases",
Cmd: exec.Command("pacman", "-Sy", "--noconfirm"),
},
{
Title: "Install jellyfin-server + jellyfin-web + jellyfin-ffmpeg",
Cmd: exec.Command("pacman", "-S", "--needed", "--noconfirm",
"jellyfin-server", "jellyfin-web", "jellyfin-ffmpeg"),
},
{
Title: "Enable and start jellyfin.service",
Cmd: exec.Command("systemctl", "enable", "--now", "jellyfin.service"),
},
}
}
// UninstallPlan reverses an install. Pass `purgeData` to also delete
// /var/lib/jellyfin (library DB + settings).
func UninstallPlan(packages PackageSet, purgeData bool) []step.Step {
var steps []step.Step
steps = append(steps, step.Step{
Title: "Stop and disable jellyfin.service",
Cmd: exec.Command("systemctl", "disable", "--now", "jellyfin.service"),
})
if names := packages.Installed(); len(names) > 0 {
args := []string{"-Rns", "--noconfirm"}
args = append(args, names...)
steps = append(steps, step.Step{
Title: "Remove " + sprintList(names),
Cmd: exec.Command("pacman", args...),
})
}
if purgeData {
steps = append(steps, step.Step{
Title: "Delete /var/lib/jellyfin (library DB + settings)",
Cmd: exec.Command("rm", "-rf", "/var/lib/jellyfin"),
})
steps = append(steps, step.Step{
Title: "Delete /etc/jellyfin and caches",
Cmd: bashAsRoot(`rm -rf /etc/jellyfin /var/cache/jellyfin /var/log/jellyfin`),
})
}
return steps
}
// ---- helpers ----
func bashAsRoot(script string) *exec.Cmd {
return exec.Command("bash", "-lc", script)
}
func sprintList(s []string) string {
return strings.Join(s, ", ")
}

View file

@ -0,0 +1,170 @@
// Package jellyfin detects what Jellyfin packages are installed on the host
// and whether the service is running. The Status type drives the Setup tab's
// "Install vs Reinstall" decision.
package jellyfin
import (
"errors"
"fmt"
"net"
"os"
"os/exec"
"strings"
)
// WebPort is Jellyfin's default HTTP port. The firewall plan opens 8096/tcp.
const WebPort = 8096
// WebURLs returns the browseable addresses for the Jellyfin web UI on this
// host: http://<hostname>.local:<port> (mDNS) plus http://<lan-ip>:<port> for
// each real non-loopback IPv4. A headless server is reached by one of these
// from a browser on the same network (or over Tailscale). Virtual/bridge
// interfaces (docker, veth, bridges) are skipped — their IPs aren't reachable.
func WebURLs() []string {
var urls []string
if h, err := os.Hostname(); err == nil && h != "" {
host := h
if !strings.Contains(host, ".") {
host += ".local"
}
urls = append(urls, fmt.Sprintf("http://%s:%d", host, WebPort))
}
ifaces, err := net.Interfaces()
if err != nil {
return urls
}
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
continue
}
n := ifc.Name
if strings.HasPrefix(n, "docker") || strings.HasPrefix(n, "br-") ||
strings.HasPrefix(n, "veth") || strings.HasPrefix(n, "virbr") {
continue
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
var ip net.IP
switch v := a.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
ip4 := ip.To4()
if ip4 == nil || ip4.IsLoopback() {
continue // skip IPv6 and loopback for the simple display
}
urls = append(urls, fmt.Sprintf("http://%s:%d", ip4.String(), WebPort))
}
}
return urls
}
// PackageSet is the union of all Jellyfin-related Arch packages we know about.
type PackageSet struct {
JellyfinBin bool // jellyfin-bin (AUR — official Microsoft-compiled binary)
Jellyfin bool // jellyfin (meta-package in official repos)
JellyfinServer bool // jellyfin-server (extra)
JellyfinWeb bool // jellyfin-web (extra)
JellyfinFFmpeg bool // jellyfin-ffmpeg
}
// Any reports whether ANY Jellyfin package is installed.
func (p PackageSet) Any() bool {
return p.JellyfinBin || p.Jellyfin || p.JellyfinServer || p.JellyfinWeb || p.JellyfinFFmpeg
}
// Installed returns the list of installed package names (in display order).
func (p PackageSet) Installed() []string {
var out []string
if p.JellyfinBin {
out = append(out, "jellyfin-bin")
}
if p.Jellyfin {
out = append(out, "jellyfin")
}
if p.JellyfinServer {
out = append(out, "jellyfin-server")
}
if p.JellyfinWeb {
out = append(out, "jellyfin-web")
}
if p.JellyfinFFmpeg {
out = append(out, "jellyfin-ffmpeg")
}
return out
}
// Status is a snapshot of how Jellyfin sits on this host.
type Status struct {
Packages PackageSet
ServiceUnit string // "jellyfin.service" if found, "" otherwise
ServiceActive bool // is jellyfin.service currently running?
UserExists bool // does the 'jellyfin' system user exist?
}
// LoadStatus inspects the host. Cheap to call repeatedly.
func LoadStatus() (Status, error) {
var s Status
s.Packages = detectPackages()
if unit, ok := detectServiceUnit(); ok {
s.ServiceUnit = unit
s.ServiceActive = isActive(unit)
}
if _, err := exec.Command("id", "-u", "jellyfin").Output(); err == nil {
s.UserExists = true
}
return s, nil
}
// IsInstalled is shorthand — anywhere we just want a boolean to drive UI.
func (s Status) IsInstalled() bool {
return s.Packages.Any() || s.ServiceUnit != ""
}
// ---- internals ----
func detectPackages() PackageSet {
return PackageSet{
JellyfinBin: pacmanInstalled("jellyfin-bin"),
Jellyfin: pacmanInstalled("jellyfin"),
JellyfinServer: pacmanInstalled("jellyfin-server"),
JellyfinWeb: pacmanInstalled("jellyfin-web"),
JellyfinFFmpeg: pacmanInstalled("jellyfin-ffmpeg"),
}
}
func pacmanInstalled(name string) bool {
cmd := exec.Command("pacman", "-Qi", name)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}
func detectServiceUnit() (string, bool) {
for _, unit := range []string{"jellyfin.service", "jellyfin-server.service"} {
out, err := exec.Command("systemctl", "list-unit-files", "--no-legend", unit).Output()
if err != nil {
continue
}
if strings.Contains(string(out), unit) {
return unit, true
}
}
return "", false
}
func isActive(unit string) bool {
cmd := exec.Command("systemctl", "is-active", "--quiet", unit)
if err := cmd.Run(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return false
}
return false
}
return true
}

View file

@ -0,0 +1,169 @@
package jellyfin
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"tuistream/internal/step"
)
// Default Arch locations for Jellyfin's growing state. The library DB,
// downloaded metadata and artwork live under the data dir; the cache dir holds
// the (regenerable) image cache and transcode scratch space.
//
// On Arch these paths are effectively pinned — Jellyfin ignores the
// JELLYFIN_DATA_DIR environment variable and always uses /var/lib/jellyfin.
// So rather than repoint Jellyfin, we relocate the *storage* underneath it
// with a bind mount: Jellyfin keeps using the default paths, but they
// physically live on the media drive.
const (
defaultDataDir = "/var/lib/jellyfin"
defaultCacheDir = "/var/cache/jellyfin"
)
// MoveOptions configures relocating Jellyfin's data + cache onto a media drive.
type MoveOptions struct {
MountPoint string // managed media-drive mount, e.g. /media/q/MediaPool
ServiceUnit string // e.g. "jellyfin.service"
}
func unitOrDefault(unit string) string {
if unit == "" {
return "jellyfin.service"
}
return unit
}
// Data/cache subpaths on the media drive — siblings of JellyfinMedia/ so a
// library scan never indexes them.
func dataDirFor(mp string) string { return filepath.Join(mp, "JellyfinData", "data") }
func cacheDirFor(mp string) string { return filepath.Join(mp, "JellyfinData", "cache") }
// StorageState describes where Jellyfin currently keeps its data.
type StorageState struct {
Moved bool // a bind mount relocating /var/lib/jellyfin is in fstab
DataDir string // bind source if moved, else the default
CacheDir string
}
// LoadStorageState reports whether Jellyfin's storage has already been
// relocated, by looking for the bind mount in /etc/fstab. Cheap to call.
func LoadStorageState() StorageState {
st := StorageState{DataDir: defaultDataDir, CacheDir: defaultCacheDir}
b, err := os.ReadFile("/etc/fstab")
if err != nil {
return st
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 || !strings.Contains(line, "bind") {
continue
}
switch fields[1] {
case defaultDataDir:
st.Moved = true
st.DataDir = fields[0]
case defaultCacheDir:
st.CacheDir = fields[0]
}
}
return st
}
// MovePlan builds the ordered steps to relocate Jellyfin's data + cache onto
// the media drive via bind mounts, then restart from the (unchanged) default
// paths and reclaim the OS-drive space.
//
// Why bind mounts: Jellyfin on Arch always uses /var/lib/jellyfin regardless of
// JELLYFIN_DATA_DIR, so we make that path *be* the media drive. The unit's
// existing WorkingDirectory=/var/lib/jellyfin makes systemd auto-wait for the
// mount, and x-systemd.requires-mounts-for chains it behind the media drive —
// so ordering is handled with no service edits.
//
// The original data is emptied only after a verified non-empty copy exists on
// the media drive, so a failure mid-run never loses the library.
func MovePlan(opts MoveOptions) []step.Step {
unit := unitOrDefault(opts.ServiceUnit)
mp := opts.MountPoint
data := dataDirFor(mp)
cache := cacheDirFor(mp)
return []step.Step{
{
Title: "Stop " + unit,
Cmd: exec.Command("systemctl", "stop", unit),
},
{
Title: "Create JellyfinData/{data,cache} on the media drive",
Cmd: bashAsRoot(fmt.Sprintf(
"install -d -o jellyfin -g jellyfin -m 0750 %s %s %s",
shellQuote(filepath.Join(mp, "JellyfinData")),
shellQuote(data), shellQuote(cache))),
},
{
Title: "Copy library DB, metadata & artwork → media drive",
Cmd: bashAsRoot(fmt.Sprintf(`
if command -v rsync >/dev/null 2>&1; then
rsync -aHAX %[1]s/ %[2]s/
else
cp -a %[1]s/. %[2]s/
fi
chown -R jellyfin:jellyfin %[2]s`,
shellQuote(defaultDataDir), shellQuote(data))),
},
{
Title: "Reclaim OS-drive space (only if the copy looks complete)",
Cmd: bashAsRoot(fmt.Sprintf(`
if [ -n "$(ls -A %[1]s 2>/dev/null)" ]; then
find %[2]s -mindepth 1 -delete 2>/dev/null || true
fi
find %[3]s -mindepth 1 -delete 2>/dev/null || true
install -d %[2]s %[3]s`,
shellQuote(data), shellQuote(defaultDataDir), shellQuote(defaultCacheDir))),
},
{
Title: "Add bind mounts to /etc/fstab",
Cmd: bashAsRoot(fmt.Sprintf(`
grep -qsF ' %[2]s ' /etc/fstab || printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[1]s %[2]s >> /etc/fstab
grep -qsF ' %[4]s ' /etc/fstab || printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[3]s %[4]s >> /etc/fstab`,
shellQuote(data), defaultDataDir,
shellQuote(cache), defaultCacheDir,
mp)),
},
{
Title: "Mount the relocated storage",
Cmd: bashAsRoot(fmt.Sprintf(`
systemctl daemon-reload
mountpoint -q %[1]s || mount %[1]s
mountpoint -q %[2]s || mount %[2]s
chown -R jellyfin:jellyfin %[1]s %[2]s`,
defaultDataDir, defaultCacheDir)),
},
{
Title: "Start " + unit + " from the relocated storage",
Cmd: bashAsRoot(fmt.Sprintf(
"systemctl reset-failed %[1]s 2>/dev/null || true\nsystemctl start %[1]s", unit)),
},
}
}
// shellQuote produces a POSIX-shell-safe single-quoted token.
func shellQuote(s string) string {
if s == "" {
return "''"
}
for _, r := range s {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '.' || r == '_' || r == '-' || r == '/' || r == ':' || r == '=' || r == '@' || r == '+' || r == ',') {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
}
return s
}

13
internal/step/step.go Normal file
View file

@ -0,0 +1,13 @@
// Package step defines a single canonical "labelled command" type that the
// install / mount / firewall packages all return, and that the TUI feeds to
// tea.ExecProcess one entry at a time.
package step
import "os/exec"
// Step is one logical action of a multi-step plan — a human-readable Title
// shown in the TUI's progress view, plus the command actually executed.
type Step struct {
Title string
Cmd *exec.Cmd
}

73
internal/system/deps.go Normal file
View file

@ -0,0 +1,73 @@
// Package system handles host-level concerns that don't fit drives /
// jellyfin / firewall — most notably the one-time install of every Arch
// package tuistream's flows actually invoke.
//
// The TUI's individual flows used to install their own missing tools on
// the fly, which interrupted the user mid-action and broke the "one
// confirm screen, then runs to completion" promise. Doing it once at
// launch keeps the flows themselves dependency-free.
package system
import (
"fmt"
"os"
"os/exec"
"strings"
)
// Dep is one Arch package + the binary tuistream invokes from it. The
// binary check lets us decide whether the package is missing without
// shelling out to `pacman -Qi`, which is much slower.
type Dep struct {
Pkg string
Binary string
}
// Required is the closed set of userspace tools tuistream needs. Anything
// in Arch's `base` group (util-linux, e2fsprogs, coreutils) is assumed
// present and intentionally NOT listed.
var Required = []Dep{
{Pkg: "rsync", Binary: "rsync"}, // Manage > Copy
{Pkg: "acl", Binary: "setfacl"}, // Setup > add-drive ACL grant, Copy ACL re-grant
{Pkg: "btrfs-progs", Binary: "mkfs.btrfs"}, // Setup > format btrfs / pool
{Pkg: "xfsprogs", Binary: "mkfs.xfs"}, // Setup > format xfs
{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
}
// Missing returns the subset of Required whose binary isn't on $PATH.
func Missing() []Dep {
var out []Dep
for _, d := range Required {
if _, err := exec.LookPath(d.Binary); err != nil {
out = append(out, d)
}
}
return out
}
// EnsureInstalled checks for missing deps and runs `pacman -S --needed
// --noconfirm` for them. Output is streamed to stderr so the user can see
// what's happening before the TUI takes over the terminal.
//
// Returns nil if everything's already present or the install succeeded.
// On pacman failure, returns the error but doesn't exit the process —
// the caller decides whether to abort or warn-and-continue.
func EnsureInstalled() error {
miss := Missing()
if len(miss) == 0 {
return nil
}
pkgs := make([]string, len(miss))
for i, d := range miss {
pkgs[i] = d.Pkg
}
fmt.Fprintf(os.Stderr,
"tuistream: installing missing tools: %s\n", strings.Join(pkgs, " "))
args := append([]string{"-S", "--needed", "--noconfirm"}, pkgs...)
cmd := exec.Command("pacman", args...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}

115
internal/theme/theme.go Normal file
View file

@ -0,0 +1,115 @@
// Package theme loads the active Omarchy colour scheme so the TUI matches the
// rest of the desktop. Omarchy writes a per-theme colors.toml (accent,
// foreground, background, color0-15); we read the active one and fall back to a
// sensible built-in palette on headless / non-Omarchy systems.
package theme
import (
"os"
"os/user"
"path/filepath"
"strings"
)
// Theme is the small set of colours the TUI needs, as "#rrggbb" strings.
type Theme struct {
Accent string
Fg string
Bg string
Dim string
Muted string
Good string
Bad string
}
// Default is the palette used when no Omarchy theme is found (e.g. a headless /
// omaterm box reached over SSH). It uses ANSI palette indices rather than fixed
// hex, so the colours track whatever theme the connecting terminal uses.
func Default() Theme {
return Theme{
Accent: "4", // blue
Fg: "7", // foreground / white
Bg: "0", // background / black
Dim: "7",
Muted: "8", // bright black / grey
Good: "2", // green
Bad: "1", // red
}
}
// Load returns the active Omarchy theme's colours, or Default() if unavailable.
func Load() Theme {
// Under `sudo` HOME is /root, which never has an Omarchy theme. Prefer
// the calling user's home (via SUDO_USER) so the TUI matches the
// desktop you actually launched it from.
home := callerHome()
if home == "" {
return Default()
}
path := filepath.Join(home, ".config", "omarchy", "current", "theme", "colors.toml")
kv, ok := parse(path)
if !ok {
return Default()
}
d := Default()
pick := func(def string, keys ...string) string {
for _, k := range keys {
if v := kv[k]; v != "" {
return v
}
}
return def
}
return Theme{
Accent: pick(d.Accent, "accent", "color4"),
Fg: pick(d.Fg, "foreground", "color7"),
Bg: pick(d.Bg, "background", "color0"),
Dim: pick(d.Dim, "color7", "foreground"),
Muted: pick(d.Muted, "color8", "color7"),
Good: pick(d.Good, "color2"),
Bad: pick(d.Bad, "color1"),
}
}
// callerHome resolves the home directory of the user who launched tuistream,
// looking through `sudo` if necessary. Returns "" if nothing resolves.
func callerHome() string {
if su := os.Getenv("SUDO_USER"); su != "" && su != "root" {
if u, err := user.Lookup(su); err == nil && u.HomeDir != "" {
return u.HomeDir
}
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return ""
}
// parse reads simple `key = "#hex"` lines from an Omarchy colors.toml. It is a
// minimal parser (no TOML dependency) sufficient for that flat file.
func parse(path string) (map[string]string, bool) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
kv := make(map[string]string)
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
val := strings.Trim(strings.TrimSpace(line[eq+1:]), `"`)
if strings.HasPrefix(val, "#") {
kv[key] = val
}
}
if len(kv) == 0 {
return nil, false
}
return kv, true
}

897
internal/tui/add_drive.go Normal file
View file

@ -0,0 +1,897 @@
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"))
}

33
internal/tui/clipboard.go Normal file
View file

@ -0,0 +1,33 @@
package tui
import (
"os"
osc52 "github.com/aymanbagabas/go-osc52/v2"
tea "github.com/charmbracelet/bubbletea"
)
// copyToClipboard returns a command that copies text to the user's clipboard
// using an OSC 52 escape sequence.
//
// This is the only copy mechanism that works from a TUI running on a HEADLESS
// server reached over SSH: the sequence is interpreted by the user's *local*
// terminal emulator, so the text lands in the clipboard of the machine they're
// sitting at — no X11/Wayland needed on the server. Inside tmux we wrap it for
// passthrough; that requires `set -g allow-passthrough on` (or `set -g
// set-clipboard on`) in the user's tmux, and a terminal that supports OSC 52
// (kitty, ghostty, wezterm, iTerm2, foot, …).
//
// The sequence is zero-width and moves no cursor, so writing it to stderr does
// not disturb Bubble Tea's alt-screen rendering. Best-effort: if the terminal
// ignores OSC 52 the user can still select-to-copy with the mouse.
func copyToClipboard(text string) tea.Cmd {
return func() tea.Msg {
seq := osc52.New(text)
if os.Getenv("TMUX") != "" {
seq = seq.Tmux()
}
_, _ = seq.WriteTo(os.Stderr)
return nil
}
}

View file

@ -0,0 +1,278 @@
// File browser sub-model used by the Manage tab. Browses a directory tree
// anchored at a fixed root (a drive's mountpoint) and refuses to escape it.
//
// Key bindings:
//
// ↑/↓ move cursor
// enter dive into a folder; on a file or with "select-here" sentinel, select
// right same as enter, but always dives (no select-here behaviour)
// left / esc go up; at the root, esc cancels
// space (caller-defined) often used to select the current cwd directly
// g / G jump to top / bottom
// pgup / pgdn page
package tui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// browseResult is what update() reports back to the caller after each key.
type browseResult int
const (
browseStillBrowsing browseResult = iota
browseCancelled
browseSelected
)
// fileBrowser holds the browser's mutable state. Owned by a caller — e.g.
// the Manage tab embeds two of these (src + dst).
type fileBrowser struct {
root string // absolute path the browser is anchored at; cwd never escapes it.
cwd string // current directory being shown
rows []browseEntry
idx int
// Window for paging — recomputed on each render based on terminal height.
// We track these so PgUp/PgDn move sensibly.
viewportRows int
// Result fields, populated on browseSelected.
selectedPath string
}
type browseEntry struct {
name string
path string
isDir bool
size int64
}
// newFileBrowser opens `root` and lands the user in it.
func newFileBrowser(root string) (fileBrowser, error) {
fb := fileBrowser{root: root, cwd: root, viewportRows: 12}
if err := fb.load(); err != nil {
return fileBrowser{}, err
}
return fb, nil
}
// SelectedPath is the absolute path of the picked file/folder. Only valid
// after the browser has returned browseSelected.
func (fb *fileBrowser) SelectedPath() string { return fb.selectedPath }
func (fb *fileBrowser) load() error {
entries, err := os.ReadDir(fb.cwd)
if err != nil {
return err
}
fb.rows = fb.rows[:0]
for _, e := range entries {
// Hide dotfiles by default — most media drives have a .Trash-1000 etc.
if strings.HasPrefix(e.Name(), ".") {
continue
}
info, err := e.Info()
size := int64(0)
isDir := e.IsDir()
if err == nil {
size = info.Size()
}
fb.rows = append(fb.rows, browseEntry{
name: e.Name(),
path: filepath.Join(fb.cwd, e.Name()),
isDir: isDir,
size: size,
})
}
sort.SliceStable(fb.rows, func(i, j int) bool {
// Directories first, then files, both case-insensitive alpha.
if fb.rows[i].isDir != fb.rows[j].isDir {
return fb.rows[i].isDir
}
return strings.ToLower(fb.rows[i].name) < strings.ToLower(fb.rows[j].name)
})
fb.idx = 0
return nil
}
// update routes a key to the browser. Returns the result + an optional Cmd.
//
// Tab / Shift-Tab are intentionally NOT handled here — the caller checks
// for them first and passes them to the root model so the user can switch
// tabs mid-browse without losing their place.
func (fb *fileBrowser) update(msg tea.KeyMsg) (browseResult, tea.Cmd) {
switch msg.String() {
case "up", "k":
if fb.idx > 0 {
fb.idx--
}
case "down", "j":
if fb.idx < len(fb.rows)-1 {
fb.idx++
}
case "pgup":
fb.idx -= fb.viewportRows
if fb.idx < 0 {
fb.idx = 0
}
case "pgdown":
fb.idx += fb.viewportRows
if fb.idx > len(fb.rows)-1 {
fb.idx = len(fb.rows) - 1
}
case "home", "g":
fb.idx = 0
case "end", "G":
fb.idx = len(fb.rows) - 1
case "left", "h", "backspace":
// Step up out of cwd. At the root, this is a no-op (use esc to cancel).
if fb.cwd == fb.root {
return browseStillBrowsing, nil
}
fb.cwd = filepath.Dir(fb.cwd)
_ = fb.load()
case "esc":
return browseCancelled, nil
case "right", "l":
// Always dives if on a folder; on a file this is a no-op.
if len(fb.rows) == 0 {
return browseStillBrowsing, nil
}
cur := fb.rows[fb.idx]
if cur.isDir {
fb.cwd = cur.path
_ = fb.load()
}
case "enter":
if len(fb.rows) == 0 {
return browseStillBrowsing, nil
}
cur := fb.rows[fb.idx]
if cur.isDir {
fb.cwd = cur.path
_ = fb.load()
} else {
fb.selectedPath = cur.path
return browseSelected, nil
}
case "s", "S":
// Select the cursor row itself (works for folders too).
if len(fb.rows) == 0 {
return browseStillBrowsing, nil
}
fb.selectedPath = fb.rows[fb.idx].path
return browseSelected, nil
}
return browseStillBrowsing, nil
}
// view renders the browser as a fixed-size pane. Always reserves
// viewportRows rows of body space so the card doesn't shrink/grow as the
// user navigates between sparse and dense directories.
func (fb *fileBrowser) view(title, footerHint string) string {
const nameCol = 56 // name column width before the right-aligned size
var rows []string
rows = append(rows, titleStyle.Render(title))
rows = append(rows, "")
rows = append(rows, devNameStyle.Render(fb.cwd))
rows = append(rows, "")
// Compute the visible window so the cursor stays inside it.
start := 0
if fb.idx >= fb.viewportRows {
start = fb.idx - fb.viewportRows + 1
}
end := start + fb.viewportRows
if end > len(fb.rows) {
end = len(fb.rows)
}
bodyRows := 0
if len(fb.rows) == 0 {
rows = append(rows, " "+headerStyle.Render("(empty directory)"))
bodyRows = 1
} else {
for i := start; i < end; i++ {
row := fb.rows[i]
name := row.name
if row.isDir {
name = devNameStyle.Render(name + "/")
}
size := ""
if !row.isDir {
size = humanSize(row.size)
}
// Pad the name column so all sizes right-align.
plainName := row.name
if row.isDir {
plainName += "/"
}
padding := nameCol - len(plainName)
if padding < 1 {
padding = 1
}
body := name + strings.Repeat(" ", padding) +
headerStyle.Render(fmt.Sprintf("%6s", size))
rows = append(rows, markerLine(i == fb.idx, body))
bodyRows++
}
}
// Reserve unused viewport rows with blank space so the card height
// is constant.
for ; bodyRows < fb.viewportRows; bodyRows++ {
rows = append(rows, "")
}
// Footer: row counter + key hints, always on the same line for a
// stable card height.
counter := ""
if len(fb.rows) > 0 {
counter = fmt.Sprintf("%d of %d · ", fb.idx+1, len(fb.rows))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render(counter+footerHint))
// NOTE: deliberately NOT centeredCard — the row widths change as you
// navigate (filenames, cwd path), so centring would make the list jitter
// horizontally while scrolling. A file listing reads best left-aligned; the
// card itself is still centred on the page by the root View.
return cardStyle.Render(strings.Join(rows, "\n"))
}
// humanSize formats a byte count with the smallest sensible suffix.
func humanSize(n int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
tb = 1024 * gb
)
switch {
case n >= tb:
return fmtSize(float64(n)/tb, "T")
case n >= gb:
return fmtSize(float64(n)/gb, "G")
case n >= mb:
return fmtSize(float64(n)/mb, "M")
case n >= kb:
return fmtSize(float64(n)/kb, "K")
}
return fmt.Sprintf("%dB", n)
}
// fmtSize: one decimal under 10, none above. "1.2G", "44M", "512K".
func fmtSize(v float64, suffix string) string {
if v < 10 {
s := fmt.Sprintf("%.1f", v)
s = strings.TrimSuffix(s, ".0")
return s + suffix
}
return fmt.Sprintf("%.0f", v) + suffix
}

323
internal/tui/import_pool.go Normal file
View file

@ -0,0 +1,323 @@
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
}

View file

@ -0,0 +1,880 @@
// Manage-tab action flow: copy, delete, rename. All three drive a small
// state machine (manageStage) plus a shared file browser, then hand the
// final command off to the shared step runner on the root Model.
package tui
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/drives"
"tuistream/internal/step"
)
// manageStage is the current sub-state of the Manage tab. manageIdle is the
// default list view; manageActionMenu is the post-`c`/`d`/`n` flow. The
// "running" + "done" splashes live on the root Model and are shared with
// Setup.
type manageStage int
const (
manageIdle manageStage = iota
// Copy flow
manageCopySrcDrive // pick source (external) drive — skipped if exactly 1
manageCopySrcBrowse // browse source for file/folder to copy
manageCopyDstDrive // pick destination (managed) drive — skipped if exactly 1
manageCopyDstBrowse // browse destination for the folder to copy INTO
manageCopyConfirm
// Delete flow
manageDeleteDrive // pick a managed drive — skipped if exactly 1
manageDeleteBrowse // browse managed drive to pick the path to delete
manageDeleteConfirm
// Rename flow
manageRenameDrive // pick a managed drive — skipped if exactly 1
manageRenameBrowse // browse managed drive to pick the path to rename
manageRenameInput // textinput for the new basename
manageRenameConfirm
// Mount / unmount flows (headless boxes, no auto-mount daemon)
manageMountPick
manageMountConfirm
manageUnmountPick
manageUnmountConfirm
)
// manageAction is the action the user picked. It just disambiguates the
// drive-picker + browse sub-stages, which would otherwise need 3x as many
// enum values.
type manageActionKind int
const (
manageActNone manageActionKind = iota
manageActCopy
manageActDelete
manageActRename
manageActMount
manageActUnmount
)
// manageAction carries the action-in-flight state. Populated incrementally
// as the user advances through the sub-stages.
type manageAction struct {
kind manageActionKind
// Drive picker state — used both for the source-drive and dest-drive
// stages of Copy, and for the single drive stage of Delete/Rename.
driveChoices []drives.Drive
driveIdx int
// Resolved drives.
srcDrive drives.Drive // copy source
dstDrive drives.Drive // copy destination, or the target of delete/rename
// File browser per side. For Copy we use two; for Delete/Rename only dst.
srcBrowse fileBrowser
dstBrowse fileBrowser
// Resolved paths.
srcPath string // absolute path on the source side (Copy)
dstPath string // absolute path on the destination side (Copy = folder; Delete/Rename = target itself)
// Rename-only: textinput for the new basename + confirmation index.
rename textinput.Model
confirmIdx int // 0 = Yes, 1 = No
// Mount-only state.
mountReadOnly bool // toggled with 'w' on the confirm screen; default true
mountPoint string // computed mountpoint for the chosen drive
}
// manageModel holds Manage-tab-specific state.
type manageModel struct {
stage manageStage
active manageAction
}
func newManageModel() manageModel { return manageModel{stage: manageIdle} }
// --- key handling ---
func handleManageKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
key := msg.String()
switch m.manage.stage {
case manageIdle:
return manageIdleKey(m, key)
case manageCopySrcDrive:
return manageDrivePickerKey(m, key, manageCopySrcBrowse, manageActCopy, true)
case manageCopySrcBrowse:
return manageCopySrcBrowseKey(m, msg)
case manageCopyDstDrive:
return manageDrivePickerKey(m, key, manageCopyDstBrowse, manageActCopy, false)
case manageCopyDstBrowse:
return manageCopyDstBrowseKey(m, msg)
case manageCopyConfirm:
return manageCopyConfirmKey(m, key)
case manageDeleteDrive:
return manageDrivePickerKey(m, key, manageDeleteBrowse, manageActDelete, false)
case manageDeleteBrowse:
return manageDeleteBrowseKey(m, msg)
case manageDeleteConfirm:
return manageDeleteConfirmKey(m, key)
case manageRenameDrive:
return manageDrivePickerKey(m, key, manageRenameBrowse, manageActRename, false)
case manageRenameBrowse:
return manageRenameBrowseKey(m, msg)
case manageRenameInput:
return manageRenameInputKey(m, msg)
case manageRenameConfirm:
return manageRenameConfirmKey(m, key)
case manageMountPick:
return manageMountPickKey(m, key)
case manageMountConfirm:
return manageMountConfirmKey(m, key)
case manageUnmountPick:
return manageUnmountPickKey(m, key)
case manageUnmountConfirm:
return manageUnmountConfirmKey(m, key)
}
return m, nil, false
}
func manageIdleKey(m Model, key string) (Model, tea.Cmd, bool) {
if m.inventory == nil {
return m, nil, false
}
switch key {
case "c":
return startCopy(m)
case "d":
return startDelete(m)
case "n":
return startRename(m)
case "m":
return startMount(m)
case "e":
return startUnmount(m)
}
return m, nil, false
}
// --- Copy: kickoff + drive picker ---
func startCopy(m Model) (Model, tea.Cmd, bool) {
ext := m.inventory.External()
if len(ext) == 0 {
m.flash = "No external drives mounted. Plug one in and press 'r'."
return m, nil, true
}
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "No managed media drives. Add one from the Setup tab first."
return m, nil, true
}
m.manage.active = manageAction{kind: manageActCopy, driveChoices: ext}
if len(ext) == 1 {
m.manage.active.srcDrive = ext[0]
fb, err := newFileBrowser(ext[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + ext[0].MountPoint + ": " + err.Error()
return m, nil, true
}
m.manage.active.srcBrowse = fb
m.manage.stage = manageCopySrcBrowse
return m, nil, true
}
m.manage.stage = manageCopySrcDrive
return m, nil, true
}
// manageDrivePickerKey is shared by every "pick a drive" sub-stage. The
// `next` arg is the sub-stage to move to after the user picks. `isCopySrc`
// tells us whether to seed srcDrive (true) or dstDrive (false).
func manageDrivePickerKey(m Model, key string, next manageStage, kind manageActionKind, isCopySrc bool) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "up", "k":
if a.driveIdx > 0 {
a.driveIdx--
}
return m, nil, true
case "down", "j":
if a.driveIdx < len(a.driveChoices)-1 {
a.driveIdx++
}
return m, nil, true
case "esc":
return manageCancel(m), nil, true
case "enter":
if a.driveIdx < 0 || a.driveIdx >= len(a.driveChoices) {
return m, nil, true
}
chosen := a.driveChoices[a.driveIdx]
fb, err := newFileBrowser(chosen.MountPoint)
if err != nil {
m.flash = "Couldn't open " + chosen.MountPoint + ": " + err.Error()
return manageCancel(m), nil, true
}
if isCopySrc {
a.srcDrive = chosen
a.srcBrowse = fb
} else {
a.dstDrive = chosen
a.dstBrowse = fb
}
_ = kind // kept for future per-action divergence
m.manage.stage = next
return m, nil, true
}
return m, nil, false
}
// manageCancel returns the user to the idle Manage view and clears any
// in-flight action state.
func manageCancel(m Model) Model {
m.manage.stage = manageIdle
m.manage.active = manageAction{}
return m
}
// --- Copy: source browser ---
func manageCopySrcBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
res, cmd := a.srcBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
// User picked something to copy. Advance to destination drive.
a.srcPath = a.srcBrowse.SelectedPath()
managed := m.inventory.Managed()
a.driveChoices = managed
a.driveIdx = 0
if len(managed) == 1 {
a.dstDrive = managed[0]
fb, err := newFileBrowser(managed[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + managed[0].MountPoint + ": " + err.Error()
return manageCancel(m), nil, true
}
a.dstBrowse = fb
m.manage.stage = manageCopyDstBrowse
return m, nil, true
}
m.manage.stage = manageCopyDstDrive
return m, nil, true
}
return m, cmd, true
}
// --- Copy: destination browser ---
func manageCopyDstBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
// In the dst browser, Enter on a folder dives in (default), Space SELECTS
// the current directory as the destination — we want "copy INTO this folder".
if msg.String() == " " || msg.String() == "space" {
a.dstPath = a.dstBrowse.cwd
m.manage.stage = manageCopyConfirm
a.confirmIdx = 0
return m, nil, true
}
res, cmd := a.dstBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
// Selecting an item in the destination browser means "copy into this
// folder" — so if it's a file we still target its parent dir.
sel := a.dstBrowse.SelectedPath()
if isDir(sel) {
a.dstPath = sel
} else {
a.dstPath = filepath.Dir(sel)
}
m.manage.stage = manageCopyConfirm
a.confirmIdx = 0
return m, nil, true
}
return m, cmd, true
}
// --- Copy: confirm + run ---
func manageCopyConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.manage.active.confirmIdx = 0
return m, nil, true
case "right", "l":
m.manage.active.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.manage.active.confirmIdx = 0
return runCopy(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if m.manage.active.confirmIdx == 0 {
return runCopy(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runCopy(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
src := a.srcPath
dstDir := a.dstPath
// Guard against copying onto self.
if src == dstDir || strings.HasPrefix(dstDir, src+string(os.PathSeparator)) {
m.flash = "Refusing to copy a folder into itself."
return manageCancel(m), nil, true
}
plan := []step.Step{
{
Title: "Ensure destination exists: " + dstDir,
Cmd: exec.Command("mkdir", "-p", dstDir),
},
{
Title: fmt.Sprintf("rsync %s → %s/", filepath.Base(src), dstDir),
Cmd: exec.Command("rsync", "-a", "--info=progress2", src, dstDir+string(os.PathSeparator)),
},
{
Title: "Reapply ACL on destination",
Cmd: exec.Command("setfacl", "-R", "-m", "u:jellyfin:rX", dstDir),
},
}
m.run = planRun{
title: "Copying " + src,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Delete: kickoff + browser ---
func startDelete(m Model) (Model, tea.Cmd, bool) {
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "No managed media drives to delete from."
return m, nil, true
}
m.manage.active = manageAction{kind: manageActDelete, driveChoices: managed}
if len(managed) == 1 {
m.manage.active.dstDrive = managed[0]
fb, err := newFileBrowser(managed[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + managed[0].MountPoint + ": " + err.Error()
return m, nil, true
}
m.manage.active.dstBrowse = fb
m.manage.stage = manageDeleteBrowse
return m, nil, true
}
m.manage.stage = manageDeleteDrive
return m, nil, true
}
func manageDeleteBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
res, cmd := a.dstBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
sel := a.dstBrowse.SelectedPath()
if sel == "" {
m.flash = "Pick a file or folder first."
return m, nil, true
}
if !isUnderRoot(sel, a.dstDrive.MountPoint) {
m.flash = "Refusing to delete outside the drive root."
return manageCancel(m), nil, true
}
if sel == a.dstDrive.MountPoint {
m.flash = "Refusing to delete the drive root."
return m, nil, true
}
a.dstPath = sel
m.manage.stage = manageDeleteConfirm
a.confirmIdx = 1 // default No for destructive op
return m, nil, true
}
return m, cmd, true
}
func manageDeleteConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.manage.active.confirmIdx = 0
return m, nil, true
case "right", "l":
m.manage.active.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.manage.active.confirmIdx = 0
return runDelete(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if m.manage.active.confirmIdx == 0 {
return runDelete(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runDelete(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
if a.dstPath == "" || a.dstPath == a.dstDrive.MountPoint {
m.flash = "Refusing to delete the drive root."
return manageCancel(m), nil, true
}
plan := []step.Step{
{
Title: "Delete: " + a.dstPath,
Cmd: exec.Command("rm", "-rf", "--", a.dstPath),
},
}
m.run = planRun{
title: "Deleting " + a.dstPath,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Rename: kickoff, browser, input, confirm ---
func startRename(m Model) (Model, tea.Cmd, bool) {
managed := m.inventory.Managed()
if len(managed) == 0 {
m.flash = "No managed media drives to rename in."
return m, nil, true
}
m.manage.active = manageAction{kind: manageActRename, driveChoices: managed}
if len(managed) == 1 {
m.manage.active.dstDrive = managed[0]
fb, err := newFileBrowser(managed[0].MountPoint)
if err != nil {
m.flash = "Couldn't open " + managed[0].MountPoint + ": " + err.Error()
return m, nil, true
}
m.manage.active.dstBrowse = fb
m.manage.stage = manageRenameBrowse
return m, nil, true
}
m.manage.stage = manageRenameDrive
return m, nil, true
}
func manageRenameBrowseKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
if k := msg.String(); k == "tab" || k == "shift+tab" {
return m, nil, false
}
res, cmd := a.dstBrowse.update(msg)
switch res {
case browseStillBrowsing:
return m, cmd, true
case browseCancelled:
return manageCancel(m), nil, true
case browseSelected:
sel := a.dstBrowse.SelectedPath()
if sel == "" || sel == a.dstDrive.MountPoint {
m.flash = "Pick a file or folder (not the drive root)."
return m, nil, true
}
a.dstPath = sel
ti := textinput.New()
ti.Prompt = ""
ti.CharLimit = 128
ti.Width = 48
ti.SetValue(filepath.Base(sel))
ti.CursorEnd()
ti.Focus()
a.rename = ti
m.manage.stage = manageRenameInput
return m, nil, true
}
return m, cmd, true
}
func manageRenameInputKey(m Model, msg tea.KeyMsg) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch msg.String() {
case "esc":
return manageCancel(m), nil, true
case "enter":
newName := strings.TrimSpace(a.rename.Value())
if newName == "" || strings.ContainsRune(newName, '/') {
m.flash = "Name can't be empty or contain '/'."
return m, nil, true
}
if newName == filepath.Base(a.dstPath) {
m.flash = "New name is the same — nothing to do."
return m, nil, true
}
newPath := filepath.Join(filepath.Dir(a.dstPath), newName)
if _, err := os.Stat(newPath); err == nil {
m.flash = "A file/folder named '" + newName + "' already exists here."
return m, nil, true
}
m.manage.stage = manageRenameConfirm
a.confirmIdx = 0
return m, nil, true
case "tab", "shift+tab":
return m, nil, false // let the root model handle tab-switch
}
var cmd tea.Cmd
a.rename, cmd = a.rename.Update(msg)
return m, cmd, true
}
func manageRenameConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
switch key {
case "left", "h":
m.manage.active.confirmIdx = 0
return m, nil, true
case "right", "l":
m.manage.active.confirmIdx = 1
return m, nil, true
case "y", "Y":
m.manage.active.confirmIdx = 0
return runRename(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if m.manage.active.confirmIdx == 0 {
return runRename(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runRename(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
newName := strings.TrimSpace(a.rename.Value())
newPath := filepath.Join(filepath.Dir(a.dstPath), newName)
plan := []step.Step{
{
Title: fmt.Sprintf("Rename %s → %s", filepath.Base(a.dstPath), newName),
Cmd: exec.Command("mv", "--", a.dstPath, newPath),
},
}
m.run = planRun{
title: "Renaming " + a.dstPath,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Mount: kickoff, picker, confirm, run ---
func startMount(m Model) (Model, tea.Cmd, bool) {
cands := m.inventory.Mountable()
if len(cands) == 0 {
m.flash = "No mountable drives detected. Plug one in and press 'r'."
return m, nil, true
}
m.manage.active = manageAction{
kind: manageActMount,
driveChoices: cands,
mountReadOnly: true,
}
m.manage.stage = manageMountPick
return m, nil, true
}
func manageMountPickKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "up", "k":
if a.driveIdx > 0 {
a.driveIdx--
}
return m, nil, true
case "down", "j":
if a.driveIdx < len(a.driveChoices)-1 {
a.driveIdx++
}
return m, nil, true
case "esc":
return manageCancel(m), nil, true
case "enter":
if a.driveIdx < 0 || a.driveIdx >= len(a.driveChoices) {
return m, nil, true
}
a.srcDrive = a.driveChoices[a.driveIdx]
a.mountPoint = filepath.Join("/run/media", m.username, mountSlug(a.srcDrive))
m.manage.stage = manageMountConfirm
a.confirmIdx = 0
return m, nil, true
}
return m, nil, false
}
func manageMountConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "left", "h":
a.confirmIdx = 0
return m, nil, true
case "right", "l":
a.confirmIdx = 1
return m, nil, true
case "w":
a.mountReadOnly = !a.mountReadOnly
return m, nil, true
case "y", "Y":
a.confirmIdx = 0
return runMount(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if a.confirmIdx == 0 {
return runMount(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runMount(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
mountOpts := "ro"
if !a.mountReadOnly {
mountOpts = "rw"
}
// Many removable drives (USB-attached MS-DOS-style partition tables on
// vfat / exfat) prefer the calling user owning the files. Pass uid/gid
// where the FS supports those mount options.
uid, gid := callerUIDGID(m.username)
switch a.srcDrive.FSType {
case "vfat", "exfat", "ntfs", "ntfs3":
mountOpts += fmt.Sprintf(",uid=%d,gid=%d", uid, gid)
}
plan := []step.Step{
{
Title: "Create mountpoint: " + a.mountPoint,
Cmd: exec.Command("mkdir", "-p", a.mountPoint),
},
{
Title: fmt.Sprintf("mount -o %s %s → %s", mountOpts, a.srcDrive.Path, a.mountPoint),
Cmd: exec.Command("mount", "-o", mountOpts, a.srcDrive.Path, a.mountPoint),
},
}
m.run = planRun{
title: "Mounting " + a.srcDrive.Path,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// --- Unmount: kickoff, picker, confirm, run ---
func startUnmount(m Model) (Model, tea.Cmd, bool) {
ext := m.inventory.External()
if len(ext) == 0 {
m.flash = "No external drives are mounted. Nothing to eject."
return m, nil, true
}
m.manage.active = manageAction{
kind: manageActUnmount,
driveChoices: ext,
}
m.manage.stage = manageUnmountPick
return m, nil, true
}
func manageUnmountPickKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "up", "k":
if a.driveIdx > 0 {
a.driveIdx--
}
return m, nil, true
case "down", "j":
if a.driveIdx < len(a.driveChoices)-1 {
a.driveIdx++
}
return m, nil, true
case "esc":
return manageCancel(m), nil, true
case "enter":
if a.driveIdx < 0 || a.driveIdx >= len(a.driveChoices) {
return m, nil, true
}
a.srcDrive = a.driveChoices[a.driveIdx]
a.mountPoint = a.srcDrive.MountPoint
m.manage.stage = manageUnmountConfirm
a.confirmIdx = 0
return m, nil, true
}
return m, nil, false
}
func manageUnmountConfirmKey(m Model, key string) (Model, tea.Cmd, bool) {
a := &m.manage.active
switch key {
case "left", "h":
a.confirmIdx = 0
return m, nil, true
case "right", "l":
a.confirmIdx = 1
return m, nil, true
case "y", "Y":
a.confirmIdx = 0
return runUnmount(m)
case "n", "N", "esc":
return manageCancel(m), nil, true
case "enter":
if a.confirmIdx == 0 {
return runUnmount(m)
}
return manageCancel(m), nil, true
}
return m, nil, false
}
func runUnmount(m Model) (Model, tea.Cmd, bool) {
a := &m.manage.active
plan := []step.Step{
{
Title: "sync — flush pending writes",
Cmd: exec.Command("sync"),
},
{
Title: "umount " + a.mountPoint,
Cmd: exec.Command("umount", "-v", a.mountPoint),
},
}
// If the mountpoint is one we created under /run/media/<user>/, tidy
// it up. The kernel can hold the dentry briefly after umount (rmdir
// returns EBUSY) and stale "ghost" mount entries from drives that
// were unplugged without unmounting also cause EBUSY — so this step
// is best-effort: sleeps briefly, tries rmdir, and never fails the
// plan. The eject is considered successful as soon as umount returns
// 0.
if strings.HasPrefix(a.mountPoint, "/run/media/") {
plan = append(plan, step.Step{
Title: "Tidy mountpoint (best-effort): " + a.mountPoint,
Cmd: exec.Command("sh", "-c", fmt.Sprintf(
"sleep 0.3; rmdir %q 2>/dev/null; true",
a.mountPoint,
)),
})
}
m.run = planRun{
title: "Ejecting " + a.srcDrive.Path,
steps: plan,
}
m.runStage = runRunning
m.runOwnerTab = tabManage
return m, runStepCmd(plan[0]), true
}
// mountSlug picks a stable, filesystem-safe directory name for a drive:
// the label if it's clean, else "<device>-<uuid-prefix>".
func mountSlug(d drives.Drive) string {
if d.Label != "" {
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
}
}
if d.UUID != "" && len(d.UUID) >= 8 {
return filepath.Base(d.Path) + "-" + d.UUID[:8]
}
return filepath.Base(d.Path)
}
// callerUIDGID resolves a username to its numeric uid/gid. Falls back to
// 1000/1000 if the lookup fails — same default Arch's first-user setup uses.
func callerUIDGID(username string) (int, int) {
if username == "" {
return 1000, 1000
}
u, err := user.Lookup(username)
if err != nil {
return 1000, 1000
}
uid, err1 := strconv.Atoi(u.Uid)
gid, err2 := strconv.Atoi(u.Gid)
if err1 != nil || err2 != nil {
return 1000, 1000
}
return uid, gid
}
// --- small helpers ---
func isDir(path string) bool {
st, err := os.Stat(path)
if err != nil {
return false
}
return st.IsDir()
}
func isUnderRoot(path, root string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
if rel == "." {
return true
}
return !strings.HasPrefix(rel, "..")
}

388
internal/tui/model.go Normal file
View file

@ -0,0 +1,388 @@
// Package tui implements the Bubble Tea front end. Two tabs: Setup and
// Manage. Tab/Shift-Tab to switch; q to quit; '?' for help.
package tui
import (
"fmt"
"os"
"os/user"
"strings"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
"tuistream/internal/firewall"
"tuistream/internal/jellyfin"
"tuistream/internal/theme"
)
type tab int
const (
tabSetup tab = iota
tabManage
tabMonitor
tabCount = 3
)
func (t tab) String() string {
switch t {
case tabSetup:
return "Setup"
case tabManage:
return "Manage"
case tabMonitor:
return "Monitor"
}
return "?"
}
// Model is the root model. Each tab's state lives in its own field so the
// tab switch is just a number bump and we never lose work-in-progress.
type Model struct {
width, height int
currentTab tab
username string // owning Linux user (SUDO_USER if available)
inventory *drives.Inventory
inventoryErr error
status jellyfin.Status
statusErr error
firewall firewall.State
setup setupModel
manage manageModel
monitor monitorModel
// In-flight plan, shared between tabs. runOwnerTab is the tab the
// run started from — we return to it when the user dismisses the
// "done" splash.
run planRun
runStage runStage
runOwnerTab tab
// spinner ticked while a step is running in the background.
spinner spinner.Model
// flash message shown in the footer for a few seconds after an action
flash string
}
// NewModel constructs a model with the given theme. The inventory is loaded
// lazily on first View() so startup is instant even on slow lsblk hosts.
func NewModel(t theme.Theme) Model {
applyTheme(t)
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(accent).Bold(true)
return Model{
currentTab: tabSetup,
username: detectUser(),
setup: newSetupModel(),
manage: newManageModel(),
monitor: newMonitorModel(),
spinner: sp,
}
}
// Init is the Bubble Tea entry point. We kick off the first inventory load,
// Jellyfin status check, and firewall snapshot asynchronously so the UI
// paints immediately. The monitor tick fires on a 5s interval to refresh
// the Monitor tab's health snapshot.
func (m Model) Init() tea.Cmd {
return tea.Batch(
loadInventoryCmd(m.username),
loadStatusCmd(),
loadFirewallCmd(),
loadHealthCmd(m.inventory),
monitorTickCmd(),
cpuSampleCmd(nil), // seed the previous-sample slot
cpuTickCmd(),
m.spinner.Tick,
)
}
type firewallLoadedMsg struct{ state firewall.State }
func loadFirewallCmd() tea.Cmd {
return func() tea.Msg {
return firewallLoadedMsg{state: firewall.LoadState()}
}
}
type inventoryLoadedMsg struct {
inv *drives.Inventory
err error
}
func loadInventoryCmd(username string) tea.Cmd {
return func() tea.Msg {
inv, err := drives.Load(username)
return inventoryLoadedMsg{inv: inv, err: err}
}
}
// Update routes messages. Global keys (quit, tab switch) are handled here;
// tab-specific keys (Setup actions etc.) are delegated to per-tab handlers.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case inventoryLoadedMsg:
m.inventory = msg.inv
m.inventoryErr = msg.err
return m, nil
case statusLoadedMsg:
m.status = msg.status
m.statusErr = msg.err
return m, nil
case firewallLoadedMsg:
m.firewall = msg.state
return m, nil
case healthLoadedMsg:
m.monitor.snap = msg.snap
m.monitor.err = msg.err
return m, nil
case monitorTickMsg:
// Refresh on tick. Always re-arm so the next tick fires.
return m, tea.Batch(loadHealthCmd(m.inventory), monitorTickCmd())
case cpuTickMsg:
return m, tea.Batch(cpuSampleCmd(m.monitor.cpuPrev), cpuTickCmd())
case cpuSampleMsg:
m.monitor.cpuPrev = msg.sample
if len(msg.usage.Cores) == 0 {
return m, nil
}
m.monitor.cpuUsage = msg.usage
if m.monitor.cpuHist == nil || len(m.monitor.cpuHist) != len(msg.usage.Cores) {
m.monitor.cpuHist = make([][]float64, len(msg.usage.Cores))
}
for i, v := range msg.usage.Cores {
m.monitor.cpuHist[i] = append(m.monitor.cpuHist[i], v)
if len(m.monitor.cpuHist[i]) > cpuHistoryLen {
m.monitor.cpuHist[i] = m.monitor.cpuHist[i][len(m.monitor.cpuHist[i])-cpuHistoryLen:]
}
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case stepFinishedMsg:
var cmd tea.Cmd
m, cmd = advanceRun(m, msg)
return m, cmd
case tea.KeyMsg:
key := msg.String()
// While a plan is running, only allow ctrl+c — nothing else should
// fire and the user can't switch tabs out from under it.
if m.runStage == runRunning {
if key == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
// Any key dismisses the runDone splash and returns to the owning
// tab's idle state.
if m.runStage == runDone {
nm, cmd := dismissRun(m)
return nm, cmd
}
// Stage-active keys: when a modal is up on the Setup tab, route through
// its handlers FIRST so e.g. 'q' inside an input still works.
if m.currentTab == tabSetup && m.setup.stage != stageIdle {
nm, cmd, consumed := handleSetupKey(m, msg)
if consumed {
return nm, cmd
}
}
// Same for Manage tab when it has a modal open.
if m.currentTab == tabManage && m.manage.stage != manageIdle {
nm, cmd, consumed := handleManageKey(m, msg)
if consumed {
return nm, cmd
}
}
switch key {
case "ctrl+c", "q":
return m, tea.Quit
case "tab", "right":
m.currentTab = (m.currentTab + 1) % tabCount
return m, nil
case "shift+tab", "left":
m.currentTab = (m.currentTab + tabCount - 1) % tabCount
return m, nil
case "r":
m.flash = "Refreshing…"
return m, tea.Batch(loadInventoryCmd(m.username), loadStatusCmd(), loadFirewallCmd())
}
// Idle Setup-tab keys ('i' install, 'u' uninstall, 'a' add drive, 'f' firewall)
if m.currentTab == tabSetup {
if nm, cmd, consumed := handleSetupKey(m, msg); consumed {
return nm, cmd
}
}
// Idle Manage-tab keys ('c' copy, 'd' delete, 'n' rename)
if m.currentTab == tabManage {
if nm, cmd, consumed := handleManageKey(m, msg); consumed {
return nm, cmd
}
}
}
return m, nil
}
// View composes the chrome (title bar, tab bar, footer) around the active
// tab's view.
func (m Model) View() string {
if m.width == 0 || m.height == 0 {
return ""
}
// Re-stretch cards / frames to the current terminal width so every
// element drawn this frame lines up regardless of which renderer
// produced it.
applyWidth(m.width)
// Title bar is naturally full-width — centre its text content.
title := titleBarStyle.Width(m.width).Align(lipgloss.Center).Render(
"TUISTREAM — headless Jellyfin for Omarchy / Arch",
)
tabs := []string{}
for i := 0; i < tabCount; i++ {
t := tab(i)
style := tabInactiveStyle
if t == m.currentTab {
style = tabActiveStyle
}
tabs = append(tabs, style.Render(t.String()))
}
tabBar := lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(
lipgloss.JoinHorizontal(lipgloss.Top, tabs...),
)
body := ""
switch {
case m.runStage == runRunning:
body = renderRunning(m.spinner, m.run)
case m.runStage == runDone:
body = renderRunDone(m.run)
case m.currentTab == tabSetup:
body = m.setup.view(m)
case m.currentTab == tabManage:
body = m.manage.view(m)
case m.currentTab == tabMonitor:
body = m.monitor.view(m)
}
footer := m.renderFooter()
// Centre the body horizontally inside the terminal. Cards are sized
// via cardWidth(m.width); this just slides them to the middle.
chromeHeight := lipgloss.Height(title) + lipgloss.Height(tabBar) + lipgloss.Height(footer)
bodyHeight := m.height - chromeHeight
if bodyHeight < 1 {
bodyHeight = 1
}
bodyBlock := lipgloss.NewStyle().
Width(m.width).
Height(bodyHeight).
Align(lipgloss.Center).
Render(body)
return lipgloss.JoinVertical(lipgloss.Left, title, tabBar, bodyBlock, footer)
}
func (m Model) renderFooter() string {
hints := []string{
"tab/⇆ switch", "r refresh", "q quit",
}
if m.flash != "" {
return footerStyle.Width(m.width).Align(lipgloss.Center).Render(m.flash + " · " + strings.Join(hints, " · "))
}
return footerStyle.Width(m.width).Align(lipgloss.Center).Render(strings.Join(hints, " · "))
}
// detectUser returns the user we should operate on behalf of: SUDO_USER if
// present (the real user behind a sudo invocation), otherwise the current
// user. Empty string if neither resolves.
func detectUser() string {
if su := envSudoUser(); su != "" {
return su
}
if u, err := user.Current(); err == nil {
return u.Username
}
return ""
}
func envSudoUser() string {
if v := os.Getenv("SUDO_USER"); v != "" && v != "root" {
return v
}
return ""
}
// WithInventory injects an already-loaded inventory and returns the updated
// model. Useful for tests.
func (m Model) WithInventory(inv *drives.Inventory) Model {
m.inventory = inv
m.inventoryErr = nil
return m
}
// WithTab switches the active tab and returns the updated model. Tests only.
func (m Model) WithTab(i int) Model {
m.currentTab = tab(i % tabCount)
return m
}
// summaryLine renders the "n drives detected, m available" line used in both
// tabs to keep the user oriented after a refresh.
func summaryLine(inv *drives.Inventory) string {
if inv == nil {
return headerStyle.Render("Loading drives…")
}
var disks, parts, available, managed int
for _, d := range inv.All {
switch d.Type {
case "disk":
disks++
case "part", "crypt":
parts++
}
switch d.Role {
case drives.RoleAvailable:
available++
case drives.RoleManagedOurs:
managed++
}
}
msg := fmt.Sprintf(
"%d disks · %d partitions · %d available · %d already managed",
disks, parts, available, managed,
)
return headerStyle.Render(msg)
}

View file

@ -0,0 +1,750 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"tuistream/internal/firewall"
"tuistream/internal/jellyfin"
"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
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 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 "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
}
// 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 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 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"))
}
// 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"))
}

114
internal/tui/styles.go Normal file
View file

@ -0,0 +1,114 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
"tuistream/internal/theme"
)
// centered horizontally centres every line of a block within width w. Each
// line is centred independently.
func centered(s string, w int) string {
return lipgloss.NewStyle().Width(w).Align(lipgloss.Center).Render(s)
}
// centeredFrame renders content inside a bordered card with every line centred
// within the card interior.
func centeredFrame(content string) string {
return frameStyle.Align(lipgloss.Center).Render(content)
}
// centeredCard is centeredFrame's equivalent for the padded modal card style
// (confirms, pickers, progress splashes).
func centeredCard(content string) string {
return cardStyle.Align(lipgloss.Center).Render(content)
}
// Colours and styles, (re)built from the active Omarchy theme by applyTheme.
// Mirrors omarchy-send's style block so the two sibling apps look related.
var (
accent lipgloss.Color
text lipgloss.Color
dim lipgloss.Color
muted lipgloss.Color
good lipgloss.Color
bad lipgloss.Color
titleBarStyle lipgloss.Style
tabActiveStyle lipgloss.Style
tabInactiveStyle lipgloss.Style
frameStyle lipgloss.Style
cardStyle lipgloss.Style
footerStyle lipgloss.Style
titleStyle lipgloss.Style
headerStyle lipgloss.Style
labelStyle lipgloss.Style
valueStyle lipgloss.Style
roleSystemStyle lipgloss.Style // boot/OS disk — off-limits
roleAvailableStyle lipgloss.Style // ready to be picked
roleInUseStyle lipgloss.Style // already mounted somewhere
devNameStyle lipgloss.Style // /dev/... in inventory rows
)
func init() { applyTheme(theme.Default()) }
// cardWidth picks a consistent width for every card/frame the TUI draws.
// The cap (88) keeps content as a comfortably-centred column with balanced
// margins instead of filling a wide terminal edge-to-edge (which reads as
// left-bunched once the lines inside are left-aligned). The floor (40) keeps
// narrow terminals readable rather than broken. The -8 budget leaves at least
// a small margin on each side so the block always looks centred, never flush.
func cardWidth(termWidth int) int {
w := termWidth - 8
if w > 88 {
w = 88
}
if w < 40 {
w = 40
}
return w
}
// applyWidth rebuilds the width-sensitive style values for the current
// terminal width. Called once per render so all cards / frames have the
// same width, regardless of which renderer drew them.
func applyWidth(termWidth int) {
w := cardWidth(termWidth)
cardStyle = cardStyle.Width(w)
frameStyle = frameStyle.Width(w)
}
func applyTheme(t theme.Theme) {
accent = lipgloss.Color(t.Accent)
text = lipgloss.Color(t.Fg)
bg := lipgloss.Color(t.Bg)
dim = lipgloss.Color(t.Dim)
muted = lipgloss.Color(t.Muted)
good = lipgloss.Color(t.Good)
bad = lipgloss.Color(t.Bad)
titleBarStyle = lipgloss.NewStyle().Bold(true).Foreground(bg).Background(accent)
tabActiveStyle = lipgloss.NewStyle().Bold(true).Foreground(bg).Background(accent).Padding(0, 2)
tabInactiveStyle = lipgloss.NewStyle().Foreground(dim).Padding(0, 2)
frameStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(0, 1)
cardStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(1, 2)
footerStyle = lipgloss.NewStyle().Foreground(muted).Padding(0, 1)
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
headerStyle = lipgloss.NewStyle().Foreground(dim)
labelStyle = lipgloss.NewStyle().Foreground(dim).Width(14)
valueStyle = lipgloss.NewStyle().Foreground(text)
roleSystemStyle = lipgloss.NewStyle().Foreground(bad).Bold(true)
roleAvailableStyle = lipgloss.NewStyle().Foreground(good).Bold(true)
roleInUseStyle = lipgloss.NewStyle().Foreground(muted)
devNameStyle = lipgloss.NewStyle().Foreground(accent)
}

483
internal/tui/view_manage.go Normal file
View file

@ -0,0 +1,483 @@
package tui
import (
"fmt"
"path/filepath"
"strings"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
)
func (mn manageModel) view(m Model) string {
if m.inventoryErr != nil {
return centeredCard(
titleStyle.Render("Couldn't read drives") + "\n\n" +
m.inventoryErr.Error(),
)
}
if m.inventory == nil {
return centeredCard(headerStyle.Render("Loading drives…"))
}
switch mn.stage {
case manageCopySrcDrive:
return renderManageDrivePicker(&mn.active, "Pick the SOURCE drive",
"The drive containing the file/folder you want to copy.")
case manageCopySrcBrowse:
return mn.active.srcBrowse.view(
"Pick what to copy from "+mn.active.srcDrive.MountPoint,
"↑/↓ nav · ↵ open/pick · s pick here · ← up · esc cancel",
)
case manageCopyDstDrive:
return renderManageDrivePicker(&mn.active, "Pick the DESTINATION drive",
"The managed media drive to copy into.")
case manageCopyDstBrowse:
return mn.active.dstBrowse.view(
"Browse "+mn.active.dstDrive.MountPoint+" — pick destination folder",
"↑/↓ nav · ↵ open · space copy HERE · s pick cursor · ← up · esc cancel",
)
case manageCopyConfirm:
return renderCopyConfirm(&mn.active)
case manageDeleteDrive:
return renderManageDrivePicker(&mn.active, "Pick the drive to delete from",
"The managed media drive that contains the file/folder you want to delete.")
case manageDeleteBrowse:
return mn.active.dstBrowse.view(
"Pick what to DELETE from "+mn.active.dstDrive.MountPoint,
"↑/↓ nav · ↵ open · s pick cursor · ← up · esc cancel",
)
case manageDeleteConfirm:
return renderDeleteConfirm(&mn.active)
case manageRenameDrive:
return renderManageDrivePicker(&mn.active, "Pick the drive to rename inside",
"The managed media drive whose file/folder you want to rename.")
case manageRenameBrowse:
return mn.active.dstBrowse.view(
"Pick what to rename in "+mn.active.dstDrive.MountPoint,
"↑/↓ nav · ↵ open · s pick cursor · ← up · esc cancel",
)
case manageRenameInput:
return renderRenameInput(&mn.active)
case manageRenameConfirm:
return renderRenameConfirm(&mn.active)
case manageMountPick:
return renderMountPick(&mn.active)
case manageMountConfirm:
return renderMountConfirm(&mn.active)
case manageUnmountPick:
return renderUnmountPick(&mn.active)
case manageUnmountConfirm:
return renderUnmountConfirm(&mn.active)
}
// Idle: managed + external + mountable drive lists + action shortcuts.
heading := titleStyle.Render("Manage media")
managed := m.inventory.Managed()
external := m.inventory.External()
mountable := m.inventory.Mountable()
subhead := manageSummary(len(managed), len(external), len(mountable))
mediaList := renderDriveList(
"Your media drives",
managed,
"No media drives yet — add one from the Setup tab.",
)
extList := renderDriveList(
"Plugged-in drives (copy your library from these)",
external,
"Nothing plugged in. Press 'm' to mount a drive, or connect a USB drive.",
)
mountList := renderMountableList(mountable)
actions := renderManageActionBar(len(managed) > 0, len(external) > 0, len(mountable) > 0, cardWidth(m.width))
w := cardWidth(m.width)
return lipgloss.JoinVertical(lipgloss.Center,
"",
centered(heading, w),
centered(subhead, w),
"",
mediaList,
"",
extList,
"",
mountList,
"",
centered(actions, w),
)
}
// manageSummary is the plain-English subheading under "Manage media".
func manageSummary(managed, external, mountable int) string {
parts := []string{
countLabel(managed, "media drive", "media drives"),
countLabel(external, "drive plugged in", "drives plugged in"),
}
if mountable > 0 {
parts = append(parts, countLabel(mountable, "drive ready to mount", "drives ready to mount"))
}
return headerStyle.Render(strings.Join(parts, " · "))
}
// countLabel renders "0 things" / "1 thing" / "3 things".
func countLabel(n int, singular, plural string) string {
if n == 1 {
return "1 " + singular
}
return fmt.Sprintf("%d %s", n, plural)
}
func renderMountableList(list []drives.Drive) string {
rows := []string{titleStyle.Render("Ready to mount")}
if len(list) == 0 {
rows = append(rows, headerStyle.Render(
"No unmounted drives with a usable filesystem."))
return centeredFrame(strings.Join(rows, "\n"))
}
for _, d := range list {
labelOrName := d.Label
if labelOrName == "" {
labelOrName = filepath.Base(d.Path)
}
rows = append(rows, friendlyDriveRow(labelOrName, d, headerStyle.Render(d.Path)))
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderDriveList(title string, list []drives.Drive, emptyMsg string) string {
rows := []string{titleStyle.Render(title)}
if len(list) == 0 {
rows = append(rows, headerStyle.Render(emptyMsg))
return centeredFrame(strings.Join(rows, "\n"))
}
for _, d := range list {
labelOrName := d.Label
if labelOrName == "" {
labelOrName = filepath.Base(d.MountPoint)
}
if labelOrName == "" {
labelOrName = d.Path
}
mp := d.MountPoint
if mp == "" {
mp = "—"
}
rows = append(rows, friendlyDriveRow(labelOrName, d, headerStyle.Render("→ "+mp)))
}
return centeredFrame(strings.Join(rows, "\n"))
}
// friendlyDriveRow lays out one drive list row consistently: friendly name,
// human size, plain filesystem name, then a trailing dim detail (mountpoint or
// device path, supplied by the caller).
func friendlyDriveRow(name string, d drives.Drive, trailing string) string {
return " " +
devNameStyle.Render(padRight(truncate(name, 20), 21)) +
valueStyle.Render(padRight(prettySize(d.Size), 9)) +
headerStyle.Render(padRight(plainFS(d.FSType), 9)) +
trailing
}
func renderManageActionBar(hasManaged, hasExternal, hasMountable bool, width int) string {
var keys []string
if hasManaged && hasExternal {
keys = append(keys, "[c] copy")
} else {
keys = append(keys, headerStyle.Render("[c] copy"))
}
if hasManaged {
keys = append(keys, "[d] delete", "[n] rename")
} else {
keys = append(keys,
headerStyle.Render("[d] delete"),
headerStyle.Render("[n] rename"),
)
}
if hasMountable {
keys = append(keys, "[m] mount")
} else {
keys = append(keys, headerStyle.Render("[m] mount"))
}
if hasExternal {
keys = append(keys, "[e] eject")
} else {
keys = append(keys, headerStyle.Render("[e] eject"))
}
return wrapKeyBar(keys, width)
}
// wrapKeyBar joins keyed shortcut labels with " · ", wrapping cleanly at
// the separator boundary when the line would overflow the available
// width. Never splits a label across lines.
func wrapKeyBar(parts []string, width int) string {
if len(parts) == 0 {
return ""
}
const sep = " · "
var lines []string
var cur string
curWidth := 0
for _, p := range parts {
pw := lipgloss.Width(p)
needed := pw
if cur != "" {
needed += len(sep)
}
if cur != "" && curWidth+needed > width-2 {
lines = append(lines, cur)
cur = p
curWidth = pw
continue
}
if cur != "" {
cur += sep
curWidth += len(sep)
}
cur += p
curWidth += pw
}
if cur != "" {
lines = append(lines, cur)
}
// Render each wrapped line on its own so a center-aligning wrapper can
// centre each independently. Rendering them as one block would right-pad
// the shorter line, leaving its text stuck to the left when centred.
for i, l := range lines {
lines[i] = footerStyle.Render(l)
}
return strings.Join(lines, "\n")
}
func renderManageDrivePicker(a *manageAction, title, sub string) string {
var rows []string
rows = append(rows, titleStyle.Render(title))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(sub))
rows = append(rows, "")
for i, d := range a.driveChoices {
labelOrName := d.Label
if labelOrName == "" {
labelOrName = filepath.Base(d.MountPoint)
}
body := fmt.Sprintf("%-18s %-8s %-9s ", truncate(labelOrName, 18), prettySize(d.Size), plainFS(d.FSType)) +
headerStyle.Render("→ "+d.MountPoint)
rows = append(rows, markerLine(i == a.driveIdx, body))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("↑/↓ move · enter pick · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// markerLine prefixes a row with a 2-char marker column ("▸ " selected,
// " " unselected) so picker rows align regardless of styling.
func markerLine(selected bool, body string) string {
if selected {
return roleAvailableStyle.Render("▸ ") + body
}
return " " + body
}
func renderCopyConfirm(a *manageAction) string {
src := a.srcPath
dst := filepath.Join(a.dstPath, filepath.Base(src))
var rows []string
rows = append(rows, titleStyle.Render("Copy this?"))
rows = append(rows, "")
rows = append(rows, "From: "+devNameStyle.Render(src))
rows = append(rows, "To: "+devNameStyle.Render(dst))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"rsync runs in the background. Progress goes to /tmp/tuistream/last-step.log — tail it from another shell for a live view."))
rows = append(rows, "")
yes := " Yes, copy "
no := " Cancel "
if a.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"))
}
func renderDeleteConfirm(a *manageAction) string {
var rows []string
rows = append(rows, titleStyle.Render("Delete this?"))
rows = append(rows, "")
rows = append(rows, "Target: "+devNameStyle.Render(a.dstPath))
rows = append(rows, "")
rows = append(rows, roleSystemStyle.Render(
"⚠ This runs `rm -rf` and CANNOT be undone."))
rows = append(rows, "")
yes := " Yes, delete "
no := " Cancel "
if a.confirmIdx == 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 back"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderRenameInput(a *manageAction) string {
var rows []string
rows = append(rows, titleStyle.Render("Rename"))
rows = append(rows, "")
rows = append(rows, "Target: "+devNameStyle.Render(a.dstPath))
rows = append(rows, "")
rows = append(rows, "New name (basename only):")
rows = append(rows, " "+a.rename.View())
rows = append(rows, "")
rows = append(rows, footerStyle.Render("type a name · enter confirm · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderMountPick(a *manageAction) string {
var rows []string
rows = append(rows, titleStyle.Render("Mount drive"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render("Pick an unmounted drive to mount under /run/media/."))
rows = append(rows, "")
rows = append(rows, " "+headerStyle.Render(fmt.Sprintf(
"%-18s %-8s %-9s %s",
"Device", "Size", "Type", "Label",
)))
for i, d := range a.driveChoices {
label := d.Label
if label == "" {
label = "—"
}
body := fmt.Sprintf("%-18s %-8s %-9s %s",
d.Path, prettySize(d.Size), plainFS(d.FSType), label)
rows = append(rows, markerLine(i == a.driveIdx, body))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("↑/↓ move · enter pick · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderMountConfirm(a *manageAction) string {
var rows []string
rows = append(rows, titleStyle.Render("Mount this drive?"))
rows = append(rows, "")
rows = append(rows, "Device: "+devNameStyle.Render(a.srcDrive.Path)+
" ("+prettySize(a.srcDrive.Size)+", "+plainFS(a.srcDrive.FSType)+
dashLabel(a.srcDrive.Label)+")")
rows = append(rows, "Mountpoint: "+devNameStyle.Render(a.mountPoint))
mode := "[✓] read-only (safe default)"
if !a.mountReadOnly {
mode = roleSystemStyle.Render("[ ] read-only — drive will be WRITABLE")
} else {
mode = roleAvailableStyle.Render("[✓]") + " read-only (safe default)"
}
rows = append(rows, "Mode: "+mode+" "+headerStyle.Render("(w to toggle)"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"This is a transient mount — nothing is written to /etc/fstab. Use 'e' to eject when done."))
rows = append(rows, "")
yes := " Yes, mount "
no := " Cancel "
if a.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 toggle write · enter confirm · y/n shortcut · esc back"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderUnmountPick(a *manageAction) string {
var rows []string
rows = append(rows, titleStyle.Render("Eject drive"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render("Pick a mounted external drive to unmount."))
rows = append(rows, "")
for i, d := range a.driveChoices {
labelOrName := d.Label
if labelOrName == "" {
labelOrName = filepath.Base(d.Path)
}
body := fmt.Sprintf("%-18s %-8s %-9s ", truncate(labelOrName, 18), prettySize(d.Size), plainFS(d.FSType)) +
headerStyle.Render("→ "+d.MountPoint)
rows = append(rows, markerLine(i == a.driveIdx, body))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("↑/↓ move · enter pick · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
func renderUnmountConfirm(a *manageAction) string {
var rows []string
rows = append(rows, titleStyle.Render("Eject this drive?"))
rows = append(rows, "")
rows = append(rows, "Device: "+devNameStyle.Render(a.srcDrive.Path)+
" ("+prettySize(a.srcDrive.Size)+", "+plainFS(a.srcDrive.FSType)+
dashLabel(a.srcDrive.Label)+")")
rows = append(rows, "Mountpoint: "+devNameStyle.Render(a.mountPoint))
rows = append(rows, "")
rows = append(rows, headerStyle.Render(
"Runs `sync && umount`. If the mountpoint is under /run/media/ the now-empty dir is removed."))
rows = append(rows, "")
yes := " Yes, eject "
no := " Cancel "
if a.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"))
}
func dashLabel(l string) string {
if l == "" {
return ""
}
return `, label "` + l + `"`
}
func renderRenameConfirm(a *manageAction) string {
newName := strings.TrimSpace(a.rename.Value())
newPath := filepath.Join(filepath.Dir(a.dstPath), newName)
var rows []string
rows = append(rows, titleStyle.Render("Rename this?"))
rows = append(rows, "")
rows = append(rows, "From: "+devNameStyle.Render(a.dstPath))
rows = append(rows, "To: "+devNameStyle.Render(newPath))
rows = append(rows, "")
yes := " Yes, rename "
no := " Cancel "
if a.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"))
}

View file

@ -0,0 +1,569 @@
// Monitor tab — a refreshing dashboard of the host's health.
//
// Drives, btrfs pools, SMART, CPU + memory, Jellyfin service. Updates
// every 5 seconds via tea.Tick. Re-uses the inventory we already track
// for the other tabs to pick which mountpoints/disks to probe.
package tui
import (
"fmt"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
"tuistream/internal/health"
"tuistream/internal/jellyfin"
)
// monitorModel holds the latest health snapshot plus the rolling CPU
// state (sampled on its own faster tick).
type monitorModel struct {
snap *health.Snapshot
err error
// CPU sampling: prev holds the last /proc/stat snapshot so the next
// tick can compute deltas. usage is the most recent % per core +
// aggregate. history is a per-core ring buffer used to render the
// sparkline alongside the bar.
cpuPrev []health.ProcStatLine
cpuUsage health.CPUUsage
cpuHist [][]float64
}
const cpuHistoryLen = 30
func newMonitorModel() monitorModel { return monitorModel{} }
// WithHealth injects a snapshot into the model directly. For test use; the
// real flow loads via the async loadHealthCmd.
func (m Model) WithHealth(snap *health.Snapshot) Model {
m.monitor.snap = snap
return m
}
// WithCPU injects a CPU usage sample + per-core history into the model.
// For test use.
func (m Model) WithCPU(u health.CPUUsage, history [][]float64) Model {
m.monitor.cpuUsage = u
m.monitor.cpuHist = history
return m
}
// --- async loading ---
type healthLoadedMsg struct {
snap *health.Snapshot
err error
}
type monitorTickMsg struct{}
const monitorRefresh = 5 * time.Second
func monitorTickCmd() tea.Cmd {
return tea.Tick(monitorRefresh, func(time.Time) tea.Msg {
return monitorTickMsg{}
})
}
// Fast tick for CPU only — cheap, no shell-outs.
type cpuTickMsg struct{}
const cpuRefresh = 1 * time.Second
func cpuTickCmd() tea.Cmd {
return tea.Tick(cpuRefresh, func(time.Time) tea.Msg {
return cpuTickMsg{}
})
}
type cpuSampleMsg struct {
sample []health.ProcStatLine
usage health.CPUUsage
}
// cpuSampleCmd reads /proc/stat in the background and returns a delta
// against prev. First sample (prev nil) returns empty usage — the next
// tick produces real numbers.
func cpuSampleCmd(prev []health.ProcStatLine) tea.Cmd {
return func() tea.Msg {
now, err := health.ReadProcStat()
if err != nil {
return cpuSampleMsg{}
}
return cpuSampleMsg{sample: now, usage: health.DeltaCPU(prev, now)}
}
}
// loadHealthCmd derives the health Inputs from the current inventory and
// kicks off a snapshot in the background. If inventory hasn't loaded
// yet we still take a snapshot — system/jellyfin/cpu sections work
// without it.
func loadHealthCmd(inv *drives.Inventory) tea.Cmd {
in := healthInputsFrom(inv)
return func() tea.Msg {
s := health.Take(in)
return healthLoadedMsg{snap: &s}
}
}
func healthInputsFrom(inv *drives.Inventory) health.Inputs {
if inv == nil {
return health.Inputs{}
}
var in health.Inputs
seenDisk := map[string]bool{}
for _, d := range inv.All {
// Capacities: every mounted filesystem we might care about.
if d.MountPoint != "" {
in.Capacities = append(in.Capacities, health.CapacityTarget{
Mountpoint: d.MountPoint,
Label: capacityLabel(d),
FSType: d.FSType,
})
}
// btrfs pools (managed-ours only; others belong to the user
// or are system).
if d.FSType == "btrfs" && d.Role == drives.RoleManagedOurs {
label := d.Label
if label == "" {
label = filepath.Base(d.MountPoint)
}
in.BtrfsMounts = append(in.BtrfsMounts, health.BtrfsTarget{
Mountpoint: d.MountPoint,
Label: label,
})
}
// Physical disks (real spinning rust / SSDs only — skip
// zram, loop, dm-mapper).
if d.Type == "disk" && !skipDiskForSmart(d.Name) && !seenDisk[d.Name] {
seenDisk[d.Name] = true
in.PhysicalDisks = append(in.PhysicalDisks, health.DiskTarget{
Device: d.Path,
Model: d.Model,
Transport: d.Transport,
})
}
}
return in
}
func skipDiskForSmart(name string) bool {
return drives.IsPseudoDisk(name)
}
func capacityLabel(d drives.Drive) string {
if d.Label != "" {
return d.Label
}
switch d.MountPoint {
case "/":
return "system"
case "/boot":
return "boot"
}
return filepath.Base(d.MountPoint)
}
// --- view ---
func (mn monitorModel) view(m Model) string {
if mn.err != nil {
return centeredCard(
titleStyle.Render("Couldn't read system health") + "\n\n" +
mn.err.Error(),
)
}
if mn.snap == nil {
return centeredCard(headerStyle.Render("Loading health snapshot…"))
}
snap := mn.snap
heading := titleStyle.Render("System health")
subhead := headerStyle.Render(
"Refreshing every " + monitorRefresh.String() +
" · last update " + snap.Time.Format("15:04:05"))
w := cardWidth(m.width)
return lipgloss.JoinVertical(lipgloss.Center,
"",
centered(heading, w),
centered(subhead, w),
"",
renderCPUCard(mn.cpuUsage, mn.cpuHist, snap.System.CPUCount),
"",
renderSystemCard(snap),
"",
renderCapacityCard(snap),
"",
renderBtrfsCard(snap),
"",
renderDiskCard(snap),
)
}
// renderCPUCard is the live activity visualizer: aggregate bar at top,
// per-core row of "Cn XX% ▇▇▇▇░░░░ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁" — % readout,
// 12-cell bar, 30-sample sparkline. Refreshed every 1 second.
func renderCPUCard(u health.CPUUsage, history [][]float64, cores int) string {
rows := []string{titleStyle.Render("CPU activity") + headerStyle.Render(
fmt.Sprintf(" — %d cores · sampled every %s", cores, cpuRefresh))}
rows = append(rows, "")
if u.Aggregate == 0 && len(u.Cores) == 0 {
rows = append(rows, headerStyle.Render(
"Waiting for second sample… (first reading uses no prior delta)"))
return centeredFrame(strings.Join(rows, "\n"))
}
// Aggregate at the top, wider bar.
rows = append(rows, " "+padRight("ALL", 3)+" "+pctColumn(u.Aggregate)+
" "+coloredBar(u.Aggregate, 28))
if len(u.Cores) > 0 {
rows = append(rows, "")
}
for i, pct := range u.Cores {
label := fmt.Sprintf("C%d", i)
var hist []float64
if i < len(history) {
hist = history[i]
}
rows = append(rows, " "+padRight(label, 3)+" "+pctColumn(pct)+
" "+coloredBar(pct, 12)+" "+sparkline(hist, cpuHistoryLen))
}
return centeredFrame(strings.Join(rows, "\n"))
}
// humanAge turns a SMART power-on-hours count into a readable drive age:
// years (1 decimal) for ≥1 year, days for ≥2 days, else raw hours. A drive
// reporting 70466 hours reads as "8.0y" instead of an opaque "70466h".
func humanAge(hours int) string {
switch {
case hours <= 0:
return "—"
case hours >= 8766: // 365.25 * 24
return fmt.Sprintf("%.1fy", float64(hours)/8766)
case hours >= 48:
return fmt.Sprintf("%dd", hours/24)
default:
return fmt.Sprintf("%dh", hours)
}
}
// pctColumn renders a 5-char " XX%" column, colored against load.
func pctColumn(pct float64) string {
style := roleAvailableStyle
switch {
case pct >= 90:
style = roleSystemStyle
case pct >= 70:
style = roleInUseStyle
}
return style.Render(fmt.Sprintf("%4.0f%%", pct))
}
// coloredBar is the inline % bar used by the CPU card. Same colour
// bucket logic as the capacity bar.
func coloredBar(pct float64, width int) string {
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
filled := int(pct / 100 * float64(width))
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
style := roleAvailableStyle
switch {
case pct >= 90:
style = roleSystemStyle
case pct >= 70:
style = roleInUseStyle
}
return style.Render(bar)
}
// sparkline renders up to `max` samples as a single line of unicode
// block elements. Missing samples render as a space so the line stays
// the same width as it fills up. Anything non-zero shows at least the
// smallest block so a mostly-idle box still has a visible trace.
func sparkline(history []float64, max int) string {
const blocks = " ▁▂▃▄▅▆▇█"
chars := []rune(blocks)
out := make([]rune, max)
for i := range out {
out[i] = ' '
}
start := 0
if len(history) > max {
start = len(history) - max
}
off := max - (len(history) - start)
for i, v := range history[start:] {
var idx int
switch {
case v <= 0:
idx = 0
case v >= 100:
idx = len(chars) - 1
default:
// Bucket 0 is "no activity"; anything > 0 must show at
// least block 1 so the trace is visible at low load.
idx = int(v/12.5) + 1
if idx >= len(chars) {
idx = len(chars) - 1
}
}
out[off+i] = chars[idx]
}
return string(out)
}
func renderSystemCard(s *health.Snapshot) string {
jVerb := roleSystemStyle.Render("not installed")
switch {
case s.Jellyfin.Active:
jVerb = roleAvailableStyle.Render("active")
case s.Jellyfin.Installed:
jVerb = roleInUseStyle.Render("installed, stopped")
}
loadCol := func(v float64) string {
// Colour a load average against CPU count: green if <= cores,
// red if > 2x cores.
cpu := float64(s.System.CPUCount)
switch {
case v >= 2*cpu:
return roleSystemStyle.Render(fmt.Sprintf("%.2f", v))
case v >= cpu:
return roleInUseStyle.Render(fmt.Sprintf("%.2f", v))
}
return roleAvailableStyle.Render(fmt.Sprintf("%.2f", v))
}
memColour := roleAvailableStyle
switch {
case s.System.MemPct >= 90:
memColour = roleSystemStyle
case s.System.MemPct >= 75:
memColour = roleInUseStyle
}
rows := []string{titleStyle.Render("System")}
rows = append(rows,
fmt.Sprintf("Load avg: %s · %s · %s over %d cores",
loadCol(s.System.LoadAvg1),
loadCol(s.System.LoadAvg5),
loadCol(s.System.LoadAvg15),
s.System.CPUCount))
rows = append(rows,
fmt.Sprintf("Memory: %s used / %s total (%s)",
health.HumanBytes(s.System.MemUsedKB*1024),
health.HumanBytes(s.System.MemTotalKB*1024),
memColour.Render(fmt.Sprintf("%.0f%%", s.System.MemPct))))
rows = append(rows,
fmt.Sprintf("Uptime: %s", health.HumanUptime(s.System.UptimeS)))
rows = append(rows, "Jellyfin: "+jVerb)
if s.Jellyfin.Active {
for i, u := range jellyfin.WebURLs() {
label := "Web URL:"
if i > 0 {
label = ""
}
rows = append(rows, fmt.Sprintf("%-11s %s", label, devNameStyle.Render(u)))
}
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderCapacityCard(s *health.Snapshot) string {
rows := []string{titleStyle.Render("Disk space")}
// Hide rows we can't measure (swap, failed statfs) — they're just noise
// to someone checking how full their media drives are.
var shown []health.Capacity
for _, c := range s.Capacities {
if c.Total == 0 || strings.EqualFold(c.FSType, "swap") {
continue
}
shown = append(shown, c)
}
if len(shown) == 0 {
rows = append(rows, headerStyle.Render("Nothing mounted yet."))
return centeredFrame(strings.Join(rows, "\n"))
}
for _, c := range shown {
name := capacityName(c)
bar := capacityBar(c.PctUsed, 20)
rows = append(rows, " "+devNameStyle.Render(padRight(truncate(name, 20), 21))+bar)
detail := fmt.Sprintf("%s free of %s · %s used",
health.HumanBytes(c.Avail),
health.HumanBytes(c.Total),
health.HumanBytes(c.Used))
rows = append(rows, " "+headerStyle.Render(detail))
}
return centeredFrame(strings.Join(rows, "\n"))
}
// capacityName picks the friendliest label for a mounted filesystem: its
// health Label if set, otherwise a plain word for the well-known system
// mounts, otherwise the last path segment.
func capacityName(c health.Capacity) string {
if c.Label != "" {
return c.Label
}
switch c.Mountpoint {
case "/":
return "System drive"
case "/boot", "/boot/efi", "/efi":
return "Boot partition"
}
return filepath.Base(c.Mountpoint)
}
// capacityBar renders a 20-cell percentage bar with colour buckets:
// green <70, amber 70-90, red >=90.
func capacityBar(pct float64, width int) string {
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
filled := int(pct / 100 * float64(width))
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
style := roleAvailableStyle
switch {
case pct >= 90:
style = roleSystemStyle
case pct >= 70:
style = roleInUseStyle
}
return style.Render(bar) + fmt.Sprintf(" %5.1f%%", pct)
}
func renderBtrfsCard(s *health.Snapshot) string {
rows := []string{titleStyle.Render("Storage pool health (Btrfs)")}
if len(s.Btrfs) == 0 {
rows = append(rows, headerStyle.Render("No storage pools — single-drive setups don't need one."))
return centeredFrame(strings.Join(rows, "\n"))
}
for i, p := range s.Btrfs {
if i > 0 {
rows = append(rows, "")
}
state := roleAvailableStyle.Render("✓ healthy")
if !p.Healthy {
state = roleSystemStyle.Render("✗ ATTENTION")
}
rows = append(rows, devNameStyle.Render(p.Label)+
" "+headerStyle.Render(p.Mountpoint)+" "+state)
if p.Note != "" {
rows = append(rows, " "+roleSystemStyle.Render(p.Note))
}
if len(p.Devices) == 0 {
rows = append(rows, " "+headerStyle.Render("(no devices reported)"))
continue
}
rows = append(rows, " "+headerStyle.Render(fmt.Sprintf(
"%-18s %6s %6s %6s %6s %6s %s",
"Device", "write", "read", "flush", "corrupt", "gen", "state",
)))
for _, d := range p.Devices {
devState := roleAvailableStyle.Render("ok")
if d.Missing {
devState = roleSystemStyle.Render("MISSING")
} else if d.WriteErrs+d.ReadErrs+d.FlushErrs+d.CorruptErrs+d.GenErrs > 0 {
devState = roleSystemStyle.Render("errors")
}
rows = append(rows, fmt.Sprintf(
" %-18s %6d %6d %6d %6d %6d %s",
d.Path, d.WriteErrs, d.ReadErrs, d.FlushErrs,
d.CorruptErrs, d.GenErrs, devState,
))
}
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderDiskCard(s *health.Snapshot) string {
rows := []string{titleStyle.Render("Drive health (SMART)")}
if len(s.Disks) == 0 {
rows = append(rows, headerStyle.Render("No physical disks detected."))
return centeredFrame(strings.Join(rows, "\n"))
}
header := " " +
padRight("Drive", 18) + " " +
padRight("Connection", 11) + " " +
padRight("Health", 8) + " " +
padLeft("Temp", 6) + " " +
padLeft("Age", 8) + " " +
padLeft("Realloc", 8) + " " +
padLeft("Pending", 8)
rows = append(rows, headerStyle.Render(header))
for _, d := range s.Disks {
var smart string
switch {
case !d.SmartReady:
smart = roleInUseStyle.Render("n/a")
case d.Passed:
smart = roleAvailableStyle.Render("✓ good")
default:
smart = roleSystemStyle.Render("✗ FAILING")
}
temp := "—"
if d.TempC > 0 {
tcol := roleAvailableStyle
if d.TempC >= 55 {
tcol = roleSystemStyle
} else if d.TempC >= 45 {
tcol = roleInUseStyle
}
temp = tcol.Render(fmt.Sprintf("%d°C", d.TempC))
}
hrs := humanAge(d.PowerOnHr)
realloc := fmt.Sprintf("%d", d.Reallocated)
pending := fmt.Sprintf("%d", d.Pending)
if d.Reallocated > 0 {
realloc = roleSystemStyle.Render(realloc)
}
if d.Pending > 0 {
pending = roleSystemStyle.Render(pending)
}
name := d.Model
if name == "" {
name = filepath.Base(d.Device)
}
row := " " +
padRight(truncate(name, 18), 18) + " " +
padRight(plainTransport(d.Transport), 11) + " " +
padRight(smart, 8) + " " +
padLeft(temp, 6) + " " +
padLeft(hrs, 8) + " " +
padLeft(realloc, 8) + " " +
padLeft(pending, 8)
rows = append(rows, row)
}
return centeredFrame(strings.Join(rows, "\n"))
}
// padRight/padLeft pad a possibly-styled string to a visible-character
// width. Plain fmt %-*s counts ANSI escape bytes, breaking alignment.
func padRight(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
func padLeft(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
return strings.Repeat(" ", width-w) + s
}

503
internal/tui/view_setup.go Normal file
View file

@ -0,0 +1,503 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"tuistream/internal/drives"
"tuistream/internal/jellyfin"
)
// setupModel holds Setup-tab-specific state: the current sub-stage (idle,
// confirm modal, add-drive flow). The "running" and "done" splash live on
// the root Model so they're shared with the Manage tab.
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"
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
// Move-Jellyfin-storage flow.
moveChoices []drives.Drive // managed media drives to choose from
movePickIdx int // cursor in the picker
moveTarget drives.Drive // the chosen destination drive
}
func newSetupModel() setupModel { return setupModel{stage: stageIdle} }
func (s setupModel) view(m Model) string {
if m.inventoryErr != nil {
return centeredCard(
titleStyle.Render("Couldn't read drives") + "\n\n" +
m.inventoryErr.Error(),
)
}
if m.inventory == nil {
return centeredCard(headerStyle.Render("Loading drives…"))
}
switch s.stage {
case stageConfirmInstall:
return renderConfirmInstall(s.confirmIdx)
case stageConfirmUninstall:
return renderConfirmUninstall(m.status, s.confirmIdx)
case stageConfirmFirewall:
return renderConfirmFirewall(m.firewall, m.setup.firewallTarget, s.confirmIdx)
case stageAddDrive:
return renderAddDrive(m)
case stageImportPool:
return renderImportPool(m)
case stageMoveJellyfinPick:
return renderMoveJellyfinPick(m)
case stageConfirmMoveJellyfin:
return renderMoveJellyfinConfirm(m)
}
status := renderStatusLine(m.status)
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))
var heading, subhead, inv string
if s.showTech {
heading = titleStyle.Render("Drives detected on this system")
subhead = summaryLine(m.inventory)
inv = renderInventory(m.inventory)
} else {
body, ready, media := renderInventoryFriendly(m.inventory)
heading = titleStyle.Render("Your drives")
subhead = friendlyDriveSummary(ready, media)
inv = body
}
hint := headerStyle.Render(techToggleHint(s.showTech))
w := cardWidth(m.width)
return lipgloss.JoinVertical(lipgloss.Center,
"",
centered(heading, w),
centered(subhead, w),
"",
centered(status, w),
centered(fw, w),
"",
inv,
centered(hint, w),
"",
centered(actions, w),
)
}
// techToggleHint is the dim one-liner under the drive list pointing at the
// 'd' toggle, worded for whichever view is currently showing.
func techToggleHint(showTech bool) string {
if showTech {
return " [d] back to the simple view"
}
return " [d] show technical details"
}
// friendlyDriveSummary is the plain-English subheading under "Your drives",
// e.g. "1 drive ready to use · none set up for media yet".
func friendlyDriveSummary(ready, media int) string {
var parts []string
switch ready {
case 0:
parts = append(parts, "no spare drives ready")
case 1:
parts = append(parts, "1 drive ready to use")
default:
parts = append(parts, fmt.Sprintf("%d drives ready to use", ready))
}
switch media {
case 0:
parts = append(parts, "none set up for media yet")
case 1:
parts = append(parts, "1 set up for media")
default:
parts = append(parts, fmt.Sprintf("%d set up for media", media))
}
return headerStyle.Render(strings.Join(parts, " · "))
}
// renderInventoryFriendly draws the default, jargon-free drive list: one row
// per physical disk with a friendly name, human size, and a plain-English
// status. Virtual devices (zram / loop) are hidden entirely. Returns the
// rendered frame plus counts of "ready" and "media" disks for the subheading.
func renderInventoryFriendly(inv *drives.Inventory) (string, int, int) {
// Device paths that can be turned into a media drive (whole disks and/or
// individual partitions), so a disk reads "Ready to use" when it OR one of
// its partitions qualifies.
cand := map[string]bool{}
for _, c := range inv.Candidates() {
cand[c.Path] = true
}
var lines []string
ready, media := 0, 0
for _, d := range inv.All {
if d.Type != "disk" {
continue
}
// Swap-in-RAM, loopback and eMMC firmware areas aren't drives the user
// thinks about.
if drives.IsPseudoDisk(d.Name) {
continue
}
text, style, detail, kind := diskFriendlyStatus(inv, d, cand)
switch kind {
case "ready":
ready++
case "media":
media++
}
name := devNameStyle.Render(fmt.Sprintf("%-24s", truncate(friendlyDriveName(d), 24)))
size := valueStyle.Render(fmt.Sprintf("%-8s", prettySize(d.Size)))
lines = append(lines, " "+name+" "+size+" "+style.Render(text))
if detail != "" {
lines = append(lines, " "+headerStyle.Render(detail))
}
}
if len(lines) == 0 {
lines = append(lines, headerStyle.Render("No drives detected."))
}
return centeredFrame(strings.Join(lines, "\n")), ready, media
}
// diskFriendlyStatus summarises a physical disk into a single plain-English
// status: the colour style to render it in, a dim one-line detail (or ""),
// and a "kind" tag ("ready"/"media"/"system"/"inuse") used for counting.
// Priority: system disk → already-our-media → ready → in-use.
func diskFriendlyStatus(inv *drives.Inventory, d drives.Drive, cand map[string]bool) (text string, style lipgloss.Style, detail, kind string) {
if d.Role == drives.RoleSystem {
return "⛌ System drive (protected)", roleSystemStyle, "Holds the operating system — left alone", "system"
}
children := inv.ChildrenOf(d.Name)
// Already set up by us as media (whole-disk or one of its partitions).
if d.Role == drives.RoleManagedOurs {
return "★ TUISTREAM media", roleAvailableStyle, "Mounted at " + d.MountPoint, "media"
}
for _, c := range children {
if c.Role == drives.RoleManagedOurs {
return "★ TUISTREAM media", roleAvailableStyle, "Mounted at " + c.MountPoint, "media"
}
}
// Spare and pickable. Both a whole disk and its partition can qualify; pick
// the most informative candidate (a labelled / formatted partition beats the
// bare disk) so the detail line says something useful.
var best *drives.Drive
consider := func(c drives.Drive) {
if !cand[c.Path] {
return
}
if best == nil || readyScore(c) > readyScore(*best) {
cc := c
best = &cc
}
}
consider(d)
for _, c := range children {
consider(c)
}
if best != nil {
return "✓ Ready to use", roleAvailableStyle, readyDetail(*best, d), "ready"
}
// Everything else is occupied — keep it short and reassuring.
if d.Role == drives.RolePoolMember {
return "• In use (storage pool)", roleInUseStyle, "", "inuse"
}
for _, c := range children {
if c.Role == drives.RolePoolMember {
return "• In use (storage pool)", roleInUseStyle, "", "inuse"
}
}
if mp := firstMount(d, children); mp != "" {
return "• In use", roleInUseStyle, "Mounted at " + mp, "inuse"
}
return "• In use", roleInUseStyle, "", "inuse"
}
// readyScore ranks ready candidates so the friendly view describes the most
// meaningful one: a labelled partition outranks a formatted-but-unlabelled one,
// which outranks a bare/blank device.
func readyScore(d drives.Drive) int {
s := 0
if d.FSType != "" {
s++
}
if d.Label != "" {
s += 2
}
return s
}
// readyDetail builds the dim hint under a "Ready to use" row: how it's
// connected, plus its label (if any) or a note that a blank disk gets
// formatted on add. `disk` supplies the transport, which lsblk only reports
// on the top-level device, not on partitions.
func readyDetail(target, disk drives.Drive) string {
bits := []string{plainTransport(disk.Transport)}
switch {
case target.Label != "":
bits = append(bits, "labelled “"+target.Label+"”")
case target.FSType == "":
bits = append(bits, "blank — formatted when you add it")
}
return strings.Join(bits, " · ")
}
// firstMount returns the first mountpoint found on the disk or its children.
func firstMount(d drives.Drive, children []drives.Drive) string {
if d.MountPoint != "" {
return d.MountPoint
}
for _, c := range children {
if c.MountPoint != "" {
return c.MountPoint
}
}
return ""
}
// friendlyDriveName is the human label for a disk: its model if lsblk knows
// it, otherwise the bare kernel name (e.g. "sda").
func friendlyDriveName(d drives.Drive) string {
if d.Model != "" {
return d.Model
}
return d.Name
}
// prettySize expands lsblk's compact size ("3.6T", "4G") into "3.6 TB" /
// "4 GB". Anything unexpected passes through unchanged.
func prettySize(s string) string {
if s == "" {
return "—"
}
switch s[len(s)-1] {
case 'K', 'M', 'G', 'T', 'P', 'E':
return s[:len(s)-1] + " " + s[len(s)-1:] + "B"
}
return s
}
// plainFS normalises the filesystem names users are most likely to be
// confused by (vfat → FAT32, ntfs3 → NTFS) and leaves the rest as-is, since
// ext4 / btrfs / xfs are recognisable to anyone setting up a media server.
func plainFS(fs string) string {
switch strings.ToLower(fs) {
case "vfat", "fat", "fat32":
return "FAT32"
case "exfat":
return "exFAT"
case "ntfs", "ntfs3":
return "NTFS"
case "":
return "unformatted"
default:
return fs
}
}
// plainTransport renders a disk's bus in friendly words.
func plainTransport(tran string) string {
switch strings.ToLower(tran) {
case "usb":
return "USB"
case "nvme":
return "NVMe SSD"
case "sata", "ata":
return "SATA"
case "":
return "internal"
default:
return strings.ToUpper(tran)
}
}
// renderMoveJellyfinPick lets the user choose which managed media drive to
// relocate Jellyfin's storage onto, when there's more than one.
func renderMoveJellyfinPick(m Model) string {
st := &m.setup
var rows []string
rows = append(rows, titleStyle.Render("Move Jellyfin storage — pick a drive"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render("Jellyfin's library DB, metadata and artwork will move onto this drive."))
rows = append(rows, "")
for i, d := range st.moveChoices {
name := d.Label
if name == "" {
name = d.MountPoint
}
body := fmt.Sprintf("%-20s %-8s ", truncate(name, 20), prettySize(d.Size)) +
headerStyle.Render("→ "+d.MountPoint)
rows = append(rows, markerLine(i == st.movePickIdx, body))
}
rows = append(rows, "")
rows = append(rows, footerStyle.Render("↑/↓ move · enter pick · esc cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// renderMoveJellyfinConfirm is the last-chance screen before migrating.
func renderMoveJellyfinConfirm(m Model) string {
st := &m.setup
target := st.moveTarget.MountPoint + "/JellyfinData"
var rows []string
rows = append(rows, titleStyle.Render("Move Jellyfin storage to the media drive?"))
rows = append(rows, "")
rows = append(rows, "Frees space on your OS drive by relocating Jellyfin's library")
rows = append(rows, "database, downloaded metadata and artwork onto the media drive.")
rows = append(rows, "")
rows = append(rows, "Data: "+devNameStyle.Render(target+"/data")+
headerStyle.Render(" (library DB, metadata, artwork)"))
rows = append(rows, "Cache: "+devNameStyle.Render(target+"/cache")+
headerStyle.Render(" (transcodes/thumbnails regenerate)"))
rows = append(rows, "")
rows = append(rows, headerStyle.Render("Jellyfin keeps using /var/lib/jellyfin, but that path is bind-mounted"))
rows = append(rows, headerStyle.Render("to the media drive via fstab. The service is stopped, data copied,"))
rows = append(rows, headerStyle.Render("the OS-drive copy reclaimed, then Jellyfin restarts. systemd mounts"))
rows = append(rows, headerStyle.Render("the drive before Jellyfin starts, so ordering is automatic."))
rows = append(rows, "")
rows = append(rows, roleSystemStyle.Render("⚠ The media drive must stay attached — if it's ever missing,"))
rows = append(rows, roleSystemStyle.Render(" Jellyfin won't start until it's back."))
rows = append(rows, "")
yes := " Yes, move 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 cancel"))
return centeredCard(strings.Join(rows, "\n"))
}
// renderInventory builds the table-style overview of every disk and partition
// with its role coloured according to whether it's available, off-limits, etc.
func renderInventory(inv *drives.Inventory) string {
header := headerStyle.Render(fmt.Sprintf(
" %-20s %-7s %-12s %-14s %s",
"Device", "Size", "Filesystem", "Label", "Used for",
))
var rows []string
rows = append(rows, header)
for i, d := range inv.All {
switch d.Type {
case "disk":
label := fmt.Sprintf("▸ %s %s %s",
devNameStyle.Render(d.Path),
d.Size,
diskDescriptor(d),
)
if d.Role == drives.RoleSystem {
label += " " + roleSystemStyle.Render("[SYSTEM DISK — off-limits]")
}
if i > 0 {
rows = append(rows, "")
}
rows = append(rows, label)
// When the disk has a filesystem directly on it (no partition
// table), show that on a sub-line — otherwise the user has no
// indication that mkfs / mount actually landed.
if d.FSType != "" && d.Role != drives.RoleSystem {
rows = append(rows, renderWholeDiskFSRow(d))
}
case "part", "crypt":
rows = append(rows, renderPartitionRow(d))
}
}
return centeredFrame(strings.Join(rows, "\n"))
}
func renderWholeDiskFSRow(d drives.Drive) string {
fs := dashIfEmpty(d.FSType)
label := dashIfEmpty(d.Label)
used := d.RoleDetail
switch d.Role {
case drives.RoleAvailable:
used = roleAvailableStyle.Render("✓ AVAILABLE (formatted but unmounted)")
case drives.RoleManagedOurs:
used = roleAvailableStyle.Render(used)
case drives.RoleMounted, drives.RolePoolMember:
used = roleInUseStyle.Render(used)
}
return fmt.Sprintf(
" └─ %-17s %-7s %-12s %-14s %s",
"(whole-disk fs)",
d.Size,
truncate(fs, 12),
truncate(label, 14),
used,
)
}
func renderPartitionRow(d drives.Drive) string {
fs := dashIfEmpty(d.FSType)
label := dashIfEmpty(d.Label)
used := d.RoleDetail
switch d.Role {
case drives.RoleAvailable:
used = roleAvailableStyle.Render("✓ AVAILABLE")
case drives.RoleSystem:
used = roleSystemStyle.Render(used)
case drives.RoleManagedOurs:
used = roleAvailableStyle.Render(used)
case drives.RoleMounted, drives.RoleLUKS, drives.RoleLVM, drives.RoleRAID, drives.RoleSwap, drives.RolePoolMember:
used = roleInUseStyle.Render(used)
}
return fmt.Sprintf(
" └─ %-17s %-7s %-12s %-14s %s",
d.Path,
d.Size,
truncate(fs, 12),
truncate(label, 14),
used,
)
}
func diskDescriptor(d drives.Drive) string {
model := d.Model
if model == "" {
model = "(unknown)"
}
tran := d.Transport
if tran == "" {
tran = "?"
}
return fmt.Sprintf("%s [%s]", model, tran)
}
func dashIfEmpty(s string) string {
if s == "" {
return "—"
}
return s
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 1 {
return s[:n]
}
return s[:n-1] + "…"
}