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:
commit
959e507a83
33 changed files with 8607 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/dist/
|
||||||
|
/tuistream
|
||||||
|
/smoketest
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
# internal working notes — not shipped
|
||||||
|
/NEXT_PHASE.md
|
||||||
|
/scratch/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
107
README.md
Normal 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
59
cmd/tuistream/main.go
Normal 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
33
go.mod
Normal 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
52
go.sum
Normal 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
104
install.sh
Executable 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
610
internal/drives/detect.go
Normal 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
57
internal/drives/format.go
Normal 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
450
internal/drives/mount.go
Normal 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
301
internal/drives/pool.go
Normal 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
|
||||||
|
}
|
||||||
193
internal/drives/pool_import.go
Normal file
193
internal/drives/pool_import.go
Normal 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
|
||||||
|
}
|
||||||
82
internal/drives/prereqs.go
Normal file
82
internal/drives/prereqs.go
Normal 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
|
||||||
|
}
|
||||||
84
internal/firewall/firewall.go
Normal file
84
internal/firewall/firewall.go
Normal 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
139
internal/health/cpu.go
Normal 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
474
internal/health/health.go
Normal 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)
|
||||||
|
}
|
||||||
75
internal/jellyfin/install.go
Normal file
75
internal/jellyfin/install.go
Normal 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, ", ")
|
||||||
|
}
|
||||||
170
internal/jellyfin/jellyfin.go
Normal file
170
internal/jellyfin/jellyfin.go
Normal 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
|
||||||
|
}
|
||||||
169
internal/jellyfin/storage.go
Normal file
169
internal/jellyfin/storage.go
Normal 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
13
internal/step/step.go
Normal 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
73
internal/system/deps.go
Normal 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
115
internal/theme/theme.go
Normal 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
897
internal/tui/add_drive.go
Normal 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
33
internal/tui/clipboard.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
278
internal/tui/file_browser.go
Normal file
278
internal/tui/file_browser.go
Normal 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
323
internal/tui/import_pool.go
Normal 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
|
||||||
|
}
|
||||||
880
internal/tui/manage_actions.go
Normal file
880
internal/tui/manage_actions.go
Normal 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
388
internal/tui/model.go
Normal 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)
|
||||||
|
}
|
||||||
750
internal/tui/setup_actions.go
Normal file
750
internal/tui/setup_actions.go
Normal 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
114
internal/tui/styles.go
Normal 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
483
internal/tui/view_manage.go
Normal 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"))
|
||||||
|
}
|
||||||
569
internal/tui/view_monitor.go
Normal file
569
internal/tui/view_monitor.go
Normal 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
503
internal/tui/view_setup.go
Normal 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] + "…"
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue