From 959e507a8386ea87a9377cbda99a7b1683e04777 Mon Sep 17 00:00:00 2001 From: 28allday Date: Sun, 31 May 2026 16:34:16 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20TUISTREAM=20=E2=80=94=20hea?= =?UTF-8?q?dless=20Jellyfin=20TUI=20for=20Omarchy/Arch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 8 + LICENSE | 21 + README.md | 107 ++++ cmd/tuistream/main.go | 59 +++ go.mod | 33 ++ go.sum | 52 ++ install.sh | 104 ++++ internal/drives/detect.go | 610 ++++++++++++++++++++++ internal/drives/format.go | 57 +++ internal/drives/mount.go | 450 +++++++++++++++++ internal/drives/pool.go | 301 +++++++++++ internal/drives/pool_import.go | 193 +++++++ internal/drives/prereqs.go | 82 +++ internal/firewall/firewall.go | 84 +++ internal/health/cpu.go | 139 +++++ internal/health/health.go | 474 +++++++++++++++++ internal/jellyfin/install.go | 75 +++ internal/jellyfin/jellyfin.go | 170 +++++++ internal/jellyfin/storage.go | 169 +++++++ internal/step/step.go | 13 + internal/system/deps.go | 73 +++ internal/theme/theme.go | 115 +++++ internal/tui/add_drive.go | 897 +++++++++++++++++++++++++++++++++ internal/tui/clipboard.go | 33 ++ internal/tui/file_browser.go | 278 ++++++++++ internal/tui/import_pool.go | 323 ++++++++++++ internal/tui/manage_actions.go | 880 ++++++++++++++++++++++++++++++++ internal/tui/model.go | 388 ++++++++++++++ internal/tui/setup_actions.go | 750 +++++++++++++++++++++++++++ internal/tui/styles.go | 114 +++++ internal/tui/view_manage.go | 483 ++++++++++++++++++ internal/tui/view_monitor.go | 569 +++++++++++++++++++++ internal/tui/view_setup.go | 503 ++++++++++++++++++ 33 files changed, 8607 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/tuistream/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 install.sh create mode 100644 internal/drives/detect.go create mode 100644 internal/drives/format.go create mode 100644 internal/drives/mount.go create mode 100644 internal/drives/pool.go create mode 100644 internal/drives/pool_import.go create mode 100644 internal/drives/prereqs.go create mode 100644 internal/firewall/firewall.go create mode 100644 internal/health/cpu.go create mode 100644 internal/health/health.go create mode 100644 internal/jellyfin/install.go create mode 100644 internal/jellyfin/jellyfin.go create mode 100644 internal/jellyfin/storage.go create mode 100644 internal/step/step.go create mode 100644 internal/system/deps.go create mode 100644 internal/theme/theme.go create mode 100644 internal/tui/add_drive.go create mode 100644 internal/tui/clipboard.go create mode 100644 internal/tui/file_browser.go create mode 100644 internal/tui/import_pool.go create mode 100644 internal/tui/manage_actions.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/setup_actions.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/view_manage.go create mode 100644 internal/tui/view_monitor.go create mode 100644 internal/tui/view_setup.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e48f32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/dist/ +/tuistream +/smoketest +*.test +*.out +# internal working notes — not shipped +/NEXT_PHASE.md +/scratch/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5acd172 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f1cd93 --- /dev/null +++ b/README.md @@ -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//` 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//`. + - 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 + `/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://: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///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). diff --git a/cmd/tuistream/main.go b/cmd/tuistream/main.go new file mode 100644 index 0000000..bb1fc7f --- /dev/null +++ b/cmd/tuistream/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f03970d --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e92ed90 --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..40c178f --- /dev/null +++ b/install.sh @@ -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" diff --git a/internal/drives/detect.go b/internal/drives/detect.go new file mode 100644 index 0000000..0576516 --- /dev/null +++ b/internal/drives/detect.go @@ -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// + 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//). 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//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// that this +// installer has previously added via fstab. Best-effort: we just enumerate +// the directories under /media// 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 +} diff --git a/internal/drives/format.go b/internal/drives/format.go new file mode 100644 index 0000000..0e2112d --- /dev/null +++ b/internal/drives/format.go @@ -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 +} diff --git a/internal/drives/mount.go b/internal/drives/mount.go new file mode 100644 index 0000000..6cbd6c7 --- /dev/null +++ b/internal/drives/mount.go @@ -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 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. 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 /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//