TUISTREAM/internal/jellyfin/storage.go
28allday 959e507a83 Initial commit: TUISTREAM — headless Jellyfin TUI for Omarchy/Arch
Three-tab Bubble Tea TUI (Setup / Manage / Monitor) for setting up and
running a headless Jellyfin server over SSH:

- Setup: install/uninstall Jellyfin, add media drives (single or btrfs
  RAID pool), import an existing detached btrfs pool non-destructively,
  firewall, move Jellyfin storage onto a media drive
- Boot-drive-safe drive classifier (walks LUKS/LVM/RAID to the physical
  disk; never offers a disk hosting /, /boot or swap)
- Keep-existing-filesystem path previews on-disk content, skips starter
  folders when content exists, and grants Jellyfin recursive read access
- Manage: copy from external drives, mount/eject, rename/delete
- Monitor: per-core CPU, load/mem/uptime, btrfs error counters, SMART

Single static binary; installs to /usr/local/bin so sudo can find it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:47:18 +01:00

169 lines
5.5 KiB
Go

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
}