TUISTREAM/internal/jellyfin/storage.go
28allday 3786d6bae1 Fix media drive not mounting after reboot (stale fstab entries)
Every fstab writer deduped on the exact line, so a stale entry for the
same mount point (e.g. the UUID of a pool that was since recreated)
survived forever. systemd-fstab-generator only creates one mount unit
per mount point and the FIRST line wins — if that line is stale, the
pool never mounts at boot, shows up as detached, and the user has to
re-import it every reboot (manual `mount -a` walks fstab sequentially,
so the import itself appeared to work).

All four writers (add-drive, create-pool, import-pool, jellyfin bind
mounts) now remove any existing entry for the target mount point
before appending the fresh line — idempotent and self-healing.

Also add a --version flag, stamped at build time via
-ldflags "-X main.version=...".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:05:37 +01:00

174 lines
5.8 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(`
# Drop any stale bind entries for these targets first (e.g. pointing at a
# previous media drive) — duplicate mount points make systemd-fstab-generator
# keep only the FIRST line, so a stale one would win at boot.
awk -v a=%[2]s -v b=%[4]s '$0 ~ /^[[:space:]]*#/ || ($2 != a && $2 != b)' /etc/fstab > /etc/fstab.tuistream \
&& cat /etc/fstab.tuistream > /etc/fstab && rm -f /etc/fstab.tuistream
printf '%%s %%s none bind,x-systemd.requires-mounts-for=%[5]s 0 0\n' %[1]s %[2]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
}