Release v1.0.0: server switching, Walker launcher, curl install, user guide

- Switch servers from the browser (s) with auto-discovery; auto-fallback to
  discovery when a saved server is unreachable, so you're never locked to a
  rebuilt box. cliamp's Jellyfin provider is re-synced on every (re)login.
- Ignore late LAN-discovery results once off the login screen (footer/input
  clobber fix).
- install.sh: curl|bash one-liner that downloads the per-arch release binary,
  still builds from a clone when Go is present; adds a floating Walker entry
  (TUI.float app-id) + icon on Omarchy desktops.
- Docs: USER_GUIDE.md plain-English walkthrough; README install one-liner,
  accurate keybindings, features section.
- Bump version to 1.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-31 17:48:03 +01:00
parent 3c64f13b92
commit b3bf49df05
8 changed files with 435 additions and 54 deletions

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
/dist/
/tuiplay
/tsplay
build.log
*.sock

View file

@ -5,8 +5,13 @@ the terminal; the selected title plays in an **mpv window** (hardware
accelerated) while the TUI acts as the remote — pause, seek, and a live progress
bar, with resume points reported back to the server.
It's the watch-side companion to [TUISTREAM](../TUISTREAM) (which sets up and
manages the Jellyfin *server*). tsplay only *plays*.
It's the watch-side companion to
[TUISTREAM](https://github.com/28allday/TUISTREAM) (which sets up and manages the
Jellyfin *server*). tsplay only *plays*.
> **New here?** The [User Guide](USER_GUIDE.md) is a plain-English, step-by-step
> walkthrough of installing, signing in, and watching. This README is the
> technical overview.
## How it works
@ -45,12 +50,28 @@ with software decoding (higher CPU use).
## Install
```bash
./install.sh
One line, nothing to clone:
```sh
curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/main/install.sh | bash
```
Installs missing deps (Arch), builds `~/.local/bin/tsplay`, then just run
`tsplay`.
This installs `mpv` if it's missing (Arch), downloads the right binary for your
architecture into `~/.local/bin`, then you just run `tsplay`. Pin a version with
`TSPLAY_VERSION=v1.0.0`, or change the prefix with `PREFIX=/opt`.
From a clone it builds from source instead (if Go is present), otherwise it falls
back to downloading the released binary:
```sh
git clone https://github.com/28allday/tsplay.git
cd tsplay && ./install.sh
```
On an **Omarchy desktop** the installer also adds a Walker entry (with icon) that
launches tsplay as a floating terminal window — search **`tsplay`** in Walker —
using the stock `TUI.float` app-id, so no Hyprland config is needed. On headless
boxes this step is skipped and tsplay stays a plain shell TUI.
## Usage
@ -63,6 +84,14 @@ subsequent runs go straight to the library.
> Discovery probes both broadcast **and** a unicast sweep of your subnet,
> because many home networks (especially WiFi) silently drop broadcast packets.
**Switching servers.** Once signed in, tsplay locks onto that server and goes
straight to the library on every later run — it won't ask again. If you move to
a different Jellyfin box, press **`s`** in the browser to drop back to discovery,
pick the new server, and sign in; the new one is then saved and locked in. If a
saved server has gone away (e.g. you rebuilt it elsewhere), tsplay notices the
failed connection on startup and falls back to discovery automatically so you're
never stuck pointing at a dead server.
### Login
| Key | Action |
@ -71,7 +100,7 @@ subsequent runs go straight to the library.
| `ctrl+n` | next discovered server (if more than one)|
| `ctrl+r` | rescan the network |
| `enter` | sign in |
| `esc` | quit |
| `esc` | cancel — back to the library, or quit if not yet signed in |
### Browse
@ -81,6 +110,7 @@ subsequent runs go straight to the library.
| `enter` / `l` / `→`| open folder · play item |
| `esc` / `←` / `h` | back |
| `c` | jump to Continue Watching |
| `s` | switch server (back to discovery)|
| `/` | filter the current list |
| `q` | quit |
@ -91,21 +121,24 @@ subsequent runs go straight to the library.
| `space` / `k` | play / pause |
| `←` / `→` | seek ∓10s |
| `shift`+`←`/`→`| seek ∓60s |
| `n` / `p` | next / previous track (music) |
| `↑` / `↓` | volume ±5 |
| `m` | mute / unmute |
| `q` / `esc` | stop, back to list|
## Status
## Features
v0.1 — LAN auto-discovery, movies/shows (season/episode drill-down)/music browse
+ direct-play, resume on start, progress/stop reporting, volume control with
remembered level, and **audio auto-advance** (music plays through the album/
folder, then returns to the browser). The UI uses a shared header/footer frame
and a compact one-line list (media-type icon, title, and a right-aligned year ·
duration · watched column).
Roadmap: search, subtitle/audio-track selection, quick-connect login, video
"play next episode".
- **LAN auto-discovery** of Jellyfin servers, with one-key **server switching**
and automatic fallback if a saved server goes away.
- Browse **movies, shows** (season/episode drill-down) **and music**, with a
compact one-line list (media-type icon, title, and a right-aligned year ·
duration · watched column) and `/` to filter.
- **Direct-play** to mpv (no server-side transcoding), **resume from your last
position**, and progress/stop reporting so **Continue Watching** stays in sync
across all your devices.
- **Audio auto-advance** — music plays through the album/folder and returns to
the browser when it's done — with volume remembered across tracks and runs.
- Colours follow the active **Omarchy** theme, with an ANSI fallback over SSH.
## Notes

209
USER_GUIDE.md Normal file
View file

@ -0,0 +1,209 @@
# tsplay — User Guide
tsplay lets you watch everything on your Jellyfin server straight from the
terminal. You browse your libraries in a tidy text interface; when you pick
something, it opens in a proper **mpv** video window (hardware-accelerated, full
quality) while the terminal becomes the remote control — pause, seek, volume,
and a live progress bar. Where you stop is remembered on the server, so you can
pick up later from any device.
This guide is for everyday use. If you want the technical details, see the
[README](README.md).
---
## 1. Installing
On an **Omarchy / Arch** machine, one line does everything:
```bash
curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/main/install.sh | bash
```
The installer:
- Installs the only thing it needs — **mpv** — if it isn't already present.
- Downloads the `tsplay` program and puts it in `~/.local/bin`.
- On an **Omarchy desktop**, it also adds tsplay to the **Walker** launcher
(with an icon), so you can start it like any other app.
That's it — nothing else to set up.
> Prefer to build it yourself? Clone the repo and run the same script — with Go
> installed it builds from source:
>
> ```bash
> git clone https://github.com/28allday/tsplay.git
> cd tsplay && ./install.sh
> ```
> **On a non-Arch system?** The installer won't install packages for you; it
> will just tell you to install `mpv` first, then re-run it.
---
## 2. Starting tsplay
Three ways, pick whichever suits you:
- **Walker (Omarchy desktop):** press your launcher key (usually `SUPER`), type
**`tsplay`**, and hit enter. It opens in a floating terminal window.
- **Terminal:** just type `tsplay`.
- **Over SSH** (e.g. on a headless box): type `tsplay`. The video window then
appears wherever mpv can open one; on a pure server with no screen, tsplay is
mainly used for music.
---
## 3. Signing in (first run)
The first time you run tsplay you'll see a short login form.
tsplay **automatically finds Jellyfin servers on your network**, so the server
address is usually filled in for you. You normally only need to type your
**username** and **password**, then press `enter`.
| Key | What it does |
|----------|------------------------------------------------|
| `tab` | Move between the boxes (server / username / password) |
| `ctrl+n` | Pick the next server, if more than one was found |
| `ctrl+r` | Scan the network again |
| `enter` | Sign in |
| `esc` | Quit |
A few notes:
- If nothing is found automatically, just type the address yourself, e.g.
`https://media.example.com` or `http://192.168.1.50:8096`.
- Your login is **saved securely** on your own machine, so you won't be asked
again — every later run goes straight to your library.
- If you don't see your server, make sure tsplay and the server are on the **same
network**, then press `ctrl+r` to rescan.
---
## 4. Finding and playing something
Once you're in, you'll see your libraries (Movies, Shows, Music, …). Move around
with the arrow keys and open things with `enter`.
| Key | What it does |
|-------------------------|---------------------------------------|
| `↑` / `↓` (or `j` / `k`)| Move up and down the list |
| `enter` (or `→`) | Open a folder, **or play** a title |
| `esc` (or `←`) | Go back one level |
| `/` | Search/filter the list you're looking at |
| `c` | Jump to **Continue Watching** |
| `s` | Switch to a different server |
| `q` | Quit tsplay |
- Drill down naturally: **Shows → a series → a season → an episode**, then
`enter` to play.
- Press `/` to filter a long list — start typing and the list narrows; press
`esc` to clear the filter.
### Continue Watching
Press **`c`** anywhere in the browser to see everything you started but didn't
finish, newest first. Pick one and it **resumes from where you left off** — no
need to scrub to the right spot. This is the same "Continue Watching" you see in
the Jellyfin app, kept in sync with your phone, TV, and browser.
---
## 5. While something is playing
The video plays in its own mpv window. Keep the **terminal focused** to use these
controls (you'll also see a live progress bar and the time):
| Key | What it does |
|-----------------|-------------------------|
| `space` (or `k`)| Play / pause |
| `←` / `→` | Skip back / forward 10s |
| `shift`+`←`/`→` | Skip back / forward 60s |
| `↑` / `↓` | Volume up / down |
| `m` | Mute / unmute |
| `n` / `p` | Next / previous track (music only) |
| `q` (or `esc`) | Stop and return to the library |
Your position is reported back to the server as you watch, so it shows up in
**Continue Watching** next time — and disappears once you finish.
---
## 6. Music
Music works just like video, but with no black window — the audio simply plays in
the background while the terminal shows what's on.
- Play an album or a track and it keeps going: when one track ends, tsplay
**automatically moves to the next** one in the album/folder, then returns to
the browser when the album finishes.
- Use `n` and `p` to jump between tracks.
- Your last volume is remembered between tracks and between runs.
---
## 7. Switching to a different server
If you set up a new Jellyfin box (or moved house and rebuilt it elsewhere), you
don't need to reinstall or wipe anything:
- Press **`s`** while browsing. tsplay drops back to the network scan, finds the
new server, and lets you sign in. From then on it locks onto the new one and
goes straight there on every run.
- If your old server has simply **gone away**, tsplay notices it can't connect
when it starts and **takes you to the scan automatically** — so you're never
stuck staring at a server that no longer exists.
(If you have the **cliamp** music player installed, switching servers updates its
Jellyfin settings too, so your music stays in sync.)
---
## 8. Troubleshooting
**"No servers found" on the login screen.**
Make sure your computer and the Jellyfin server are on the same Wi-Fi/network,
then press `ctrl+r` to scan again. You can always type the address by hand —
e.g. `http://192.168.1.50:8096`.
**"Login failed".**
Double-check the username and password. If the server address is wrong, press
`tab` back up to it and correct it (or `ctrl+r` to rediscover).
**It can't connect / the library won't load.**
tsplay will usually send you back to the server scan on its own. If you're typing
the address, prefer a plain `http://…:8096` address on your home network, or a
proper `https://` address with a real certificate. Self-signed certificates are
**not** trusted.
**Video plays but is choppy / uses lots of CPU.**
That's the graphics drivers, not tsplay. On a normal Omarchy desktop this is
already sorted. On a brand-new, minimal install you may need your GPU's
video-decode drivers (the [README](README.md#gpu-drivers-hardware-accelerated-playback)
lists them per graphics card).
**I want to start over / log in as someone else.**
Delete the saved settings and relaunch:
```bash
rm ~/.config/tsplay/config.json
tsplay
```
You'll get the fresh login screen again.
---
## 9. Quick reference
```
Browsing ↑/↓ move · enter open/play · esc back · / search
c continue watching · s switch server · q quit
Playing space pause · ←/→ ±10s · shift+←/→ ±60s
↑/↓ volume · m mute · n/p track (music) · q stop
```
Happy watching.

View file

@ -1,56 +1,146 @@
#!/usr/bin/env bash
# tsplay installer — builds the terminal Jellyfin player from source and drops
# the binary in ~/.local/bin. On Arch/Omarchy it installs the dependencies it
# needs (mpv at run-time, go at build-time) via pacman; on other distros it
# falls back to telling you what to install.
#
# install.sh — install tsplay, a terminal Jellyfin player for Omarchy / Arch.
#
# Quick install (nothing to clone):
#
# curl -fsSL https://raw.githubusercontent.com/28allday/tsplay/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 ~/.local → ~/.local/bin)
# TSPLAY_VERSION=v1.0.0 pin a release (default: latest)
#
# tsplay needs mpv at run time; the installer fetches it via pacman on Arch if
# it's missing. On an Omarchy desktop it also adds a floating Walker entry.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN_DIR="${HOME}/.local/bin"
BIN="${BIN_DIR}/tsplay"
REPO="28allday/tsplay"
NAME="tsplay"
PREFIX="${PREFIX:-$HOME/.local}"
BIN_DIR="$PREFIX/bin"
BIN="$BIN_DIR/$NAME"
APP_DIR="$HOME/.local/share/applications"
VERSION="${TSPLAY_VERSION:-latest}"
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; }
err() { printf '\033[31m%s\033[0m\n' "$1" >&2; }
# pkg_for maps a required command to its Arch package name (same here, but kept
# explicit so it's obvious what gets installed).
pkg_for() {
case "$1" in
go) echo "go" ;;
mpv) echo "mpv" ;;
*) echo "$1" ;;
esac
}
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
# ensure_dep makes sure a command exists, installing it via pacman on Arch if
# missing. $2 = "build" or "run" just tweaks the wording.
# missing. mpv is tsplay's only run-time dependency.
ensure_dep() {
local cmd="$1" kind="$2" pkg
local cmd="$1" pkg="$2"
command -v "$cmd" >/dev/null 2>&1 && return 0
pkg="$(pkg_for "$cmd")"
if command -v pacman >/dev/null 2>&1; then
bold "Installing missing ${kind} dependency: ${pkg}"
bold "Installing missing dependency: ${pkg}"
sudo pacman -S --needed --noconfirm "$pkg"
else
err "Missing ${kind} dependency '${cmd}'. Install the '${pkg}' package and re-run."
err "Missing dependency '${cmd}'. Install the '${pkg}' package and re-run."
exit 1
fi
}
# Build-time and run-time deps. mpv is the only thing tsplay needs at run time;
# everything else is compiled into the Go binary.
ensure_dep go build
ensure_dep mpv run
# mpv is required however we obtain the binary.
ensure_dep mpv mpv
bold "Building tsplay…"
cd "$REPO_DIR"
mkdir -p "$BIN_DIR"
go build -o "$BIN" ./cmd/tsplay
# 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
# Detect OS/arch for prebuilt asset names.
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$(uname -m)" in
x86_64 | amd64) arch=amd64 ;;
aarch64 | arm64) arch=arm64 ;;
*) arch="" ;;
esac
# ---- obtain the binary ----------------------------------------------------
TMPBIN=""
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.mod" ] && command -v go >/dev/null 2>&1; then
bold "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" ] && [ -n "$arch" ] && [ -x "$SCRIPT_DIR/dist/${NAME}-${os}-${arch}" ]; then
log "Using prebuilt binary from dist/"
TMPBIN="$SCRIPT_DIR/dist/${NAME}-${os}-${arch}"
else
# Download the released binary for this OS/arch (curl-style install).
[ "$os" = "linux" ] || err "tsplay ships Linux binaries only (detected: $os). Clone the repo and build with Go."
[ "$os" = "linux" ] || exit 1
[ -n "$arch" ] || { err "unsupported architecture: $(uname -m)"; exit 1; }
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
err "need curl or wget to download the binary."; exit 1
fi
fi
# ---- install --------------------------------------------------------------
install -Dm755 "$TMPBIN" "$BIN"
bold "Installed: $BIN"
# ---- Omarchy desktop integration -----------------------------------------
# Only on Omarchy: add a Walker entry that opens tsplay in a floating window.
# The TUI.float app-id is matched by Omarchy's stock floating-window rule, so
# no Hyprland configuration is required (same approach as omarchy-send).
if command -v omarchy-launch-tui >/dev/null 2>&1 || [ -d "$HOME/.local/share/omarchy" ]; then
mkdir -p "$APP_DIR"
# Install the bundled icon into the user's hicolor theme — a play triangle
# over a progress bar, in terminal-accent blue.
icon_dir="$HOME/.local/share/icons/hicolor/scalable/apps"
mkdir -p "$icon_dir"
cat > "$icon_dir/tsplay.svg" <<'SVG'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#16161e"/>
<path d="M104 78 L104 158 L172 118 Z" fill="#7aa2f7"/>
<rect x="64" y="186" width="128" height="10" rx="5" fill="#2a2e42"/>
<rect x="64" y="186" width="80" height="10" rx="5" fill="#7aa2f7"/>
</svg>
SVG
gtk-update-icon-cache -q -t -f "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
cat > "$APP_DIR/tsplay.desktop" <<EOF
[Desktop Entry]
Name=tsplay
Comment=Terminal Jellyfin player — browse your server and play in mpv
Exec=xdg-terminal-exec --app-id=TUI.float -e $BIN
Icon=tsplay
Terminal=false
Type=Application
Categories=AudioVideo;Player;Video;Network;
Keywords=jellyfin;media;player;stream;mpv;movies;tv;music;
EOF
bold "Omarchy detected — added floating Walker entry (with icon)."
echo " Launch it from Walker by searching 'tsplay'."
fi
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*":$BIN_DIR:"*) : ;;
*) warn "Note: $BIN_DIR is not on your PATH. Add it to use 'tsplay' directly." ;;
esac
echo "Run 'tsplay' to sign in to your Jellyfin server (it auto-detects LAN servers)."

View file

@ -19,7 +19,7 @@ import (
)
// Version is stamped into the Authorization header and User-Agent.
const Version = "0.1.0"
const Version = "1.0.0"
// Client talks to one Jellyfin server as one user.
type Client struct {

View file

@ -33,6 +33,9 @@ func (m Model) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "c":
return m, m.loadResume()
case "s":
// Switch to a different Jellyfin server: back to discovery/login.
return m.toLogin("")
case "enter", "l", "right":
sel, ok := cur.SelectedItem().(listItem)
if !ok {
@ -62,7 +65,7 @@ func (m Model) viewBrowse() string {
m.headerBar(), "", m.st.muted.Render(" Loading libraries…"))
}
cur := m.stack[len(m.stack)-1]
help := "enter play/open · c continue · / filter · esc back · q quit"
help := "enter play/open · c continue · / filter · s server · esc back · q quit"
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(),
"",

View file

@ -14,7 +14,18 @@ import (
func (m Model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c", "esc":
case "ctrl+c":
return m, tea.Quit
case "esc":
// If we reached login from an active session (switch server) and the
// old browse view is still around, esc cancels and returns to it. On
// genuine first-run, or after a failed-server fallback (empty stack),
// there's nothing to go back to, so esc quits.
if m.client != nil && len(m.stack) > 0 {
m.screen = screenBrowse
m.status = ""
return m, nil
}
return m, tea.Quit
case "ctrl+n":
// Cycle through discovered servers, filling the URL field.

View file

@ -79,12 +79,30 @@ func New(cfg *config.Config, t theme.Theme) Model {
m.screen = screenBrowse
} else {
m.screen = screenLogin
m.initLogin()
m.initLogin(true)
}
return m
}
func (m *Model) initLogin() {
// toLogin returns to the login/discovery screen to (re)connect to a server and
// kicks off a fresh LAN scan. The URL field is left blank so discovery can
// pre-fill the *new* server; the saved username is kept. status seeds the
// footer (empty → the default "scanning…" line). The old browse stack is left
// intact so esc can return to it; a successful login replaces it.
//
// Used both for an explicit "switch server" from the browser and as the
// fallback when the saved server can't be reached on startup (e.g. the Jellyfin
// box was rebuilt), so the user is never permanently locked to a dead server.
func (m Model) toLogin(status string) (Model, tea.Cmd) {
m.screen = screenLogin
m.initLogin(false)
if status != "" {
m.status = status
}
return m, tea.Batch(textinput.Blink, discoverCmd())
}
func (m *Model) initLogin(prefillURL bool) {
labels := []string{"Server URL (https://media.example.com)", "Username", "Password"}
m.inputs = make([]textinput.Model, 3)
for i := range m.inputs {
@ -97,7 +115,7 @@ func (m *Model) initLogin() {
}
m.inputs[i] = ti
}
if m.cfg.ServerURL != "" {
if prefillURL && m.cfg.ServerURL != "" {
m.inputs[0].SetValue(m.cfg.ServerURL)
}
if m.cfg.Username != "" {
@ -268,6 +286,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case discoveredMsg:
// Ignore late scan results that arrive after we've left the login screen
// (e.g. the user signed in before the 2s scan finished). The discovered
// list and URL pre-fill are only meaningful on the login form, and
// applying them on the browser would clobber its footer/inputs.
if m.screen != screenLogin {
return m, nil
}
m.discovered = msg.servers
m.discIdx = 0
if len(msg.servers) == 0 {
@ -296,6 +321,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cfg.UserID = msg.res.User.ID
_ = m.cfg.Save()
m.client = api.New(m.cfg.ServerURL, m.cfg.Token, m.cfg.UserID, m.cfg.DeviceID)
// Drop any browse stack from a previous server so the new server's
// libraries replace it instead of stacking on top.
m.stack = nil
m.screen = screenBrowse
m.status = ""
// Best-effort: mirror the Jellyfin credentials into cliamp's config so
@ -314,6 +342,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case levelMsg:
if msg.err != nil {
// A failed *initial* load (empty stack) means the saved server is
// unreachable or our token expired — typically the Jellyfin box was
// rebuilt elsewhere. Drop back to discovery so the user can reconnect,
// rather than dead-ending on an error footer.
if len(m.stack) == 0 {
return m.toLogin("Couldn't reach " + m.cfg.ServerURL + " — pick a server to reconnect.")
}
m.status = "Load failed: " + msg.err.Error()
return m, nil
}