From 2f93d1edbfb28b3c035e7d210e94508c45a5f4c8 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Thu, 6 Aug 2026 14:00:39 -0400 Subject: [PATCH 01/14] docs: design spec for Jellyfin variant (#56) Co-Authored-By: Claude Opus 4.8 --- .../2026-08-06-jellyfin-variant-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-jellyfin-variant-design.md diff --git a/docs/superpowers/specs/2026-08-06-jellyfin-variant-design.md b/docs/superpowers/specs/2026-08-06-jellyfin-variant-design.md new file mode 100644 index 0000000..6b7620b --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-jellyfin-variant-design.md @@ -0,0 +1,104 @@ +# Jellyfin as a Plex alternative + +**Issue:** #56 ("JellyFin Variant") +**Date:** 2026-08-06 +**Branch:** `feat/jellyfin-variant` + +## Problem + +Plex is steadily moving features behind Plex Pass and raising its price. Users want +the option to run Jellyfin instead. The issue author's request: offer both Plex and +Jellyfin as media servers, with the user commenting out the one they don't want. + +## Approach + +Add Jellyfin as a **commented-out swap** for Plex, not an always-on second server. +Plex stays the default; a user who wants Jellyfin comments out `plex:` and uncomments +`jellyfin:`. This matches the issue author's mental model, introduces no new Compose +concepts, and avoids the `depends_on` cascade that Compose profiles would trigger +(only `tautulli` has `depends_on: plex`, but profiling `plex` would force `tautulli` +and every other Plex companion to be profiled too). + +Scope is **swap the media server + document the caveats honestly**. The issue author +assumed "MOST of the services work with jellyfin"; in this stack that is only partly +true, and the README must say so rather than imply a clean drop-in. + +### Companion compatibility (as it actually stands in this repo) + +- **Plex-only** — will not work against Jellyfin without replacement/reconfig: + Tautulli, Watchlistarr (syncs the *Plex* watchlist), Kometa / plex-meta-manager, + Maintainerr. The dashboard's now-playing and poster panels read *through Tautulli*, + so they are Plex-bound as well. +- **Media-server-agnostic** — work as-is: Radarr, Sonarr, Prowlarr, Bazarr, + Transmission, and Seerr (`ghcr.io/seerr-team/seerr` supports a Jellyfin backend). + +Wiring Jellyfin-native companions (Jellystat, Jellyseerr reconfiguration, etc.) is +**explicitly out of scope** for this PR and left as a follow-up, consistent with the +repo's preference for splitting PRs by concern. + +## Changes + +### 1. `docker-compose.yml` — commented-out `jellyfin:` service + +Directly under the `plex:` block, add a header comment and a fully-formed but commented +service: + +- `image: linuxserver/jellyfin`, `container_name: jellyfin`, `network_mode: "host"` + (parity with Plex; host mode aids DLNA/discovery, UI on `:8096`). +- `environment: PUID / PGID / TZ` — **no claim token** (Jellyfin has none). +- `volumes: ${USERDIR}/jellyfin/config:/config` and `${USERDIR}/plex/media:/media` + (the same media tree the *arrs write to, so libraries are shared with a Plex install + or a prior Plex layout). +- `restart: unless-stopped`. +- Header comment states the swap plainly: to use Jellyfin instead of Plex, comment out + the `plex:` service above and uncomment everything below. + +No new network entry (host mode). No named volume added — the existing `plex:` named +volume is itself vestigial (the service uses bind mounts), so that quirk is not mirrored. + +### 2. `.env.example` — comment only, no new vars + +Jellyfin needs nothing beyond the existing `PUID` / `PGID` / `TZ` / `USERDIR`. Add a +short comment near the Plex vars pointing at the Jellyfin option. Deliberately introduce +**no new env var**, so the "every var in `.env.example` is consumed by compose" contract +stays intact (a var consumed only by a commented service would violate it). + +### 3. Dashboard catalog — `dashboard/server/src/services.ts` + +Add one entry to the MEDIA group: + +```ts +{ id: 'jellyfin', name: 'Jellyfin', mono: 'JF', container: 'jellyfin', + group: 'media', hue: 'violet', port: 8096, blurb: 'Alternative media server' } +``` + +`container: 'jellyfin'` matches the commented block's `container_name`, so the panel +turns healthy on its own the moment a user uncomments the service — no dashboard restart. +Until then it reports `absent`, which the health UI already handles. The user accepted +that Plex installs will show an idle Jellyfin tile. + +### 4. `README.md` — "Using Jellyfin instead of Plex" section + +The swap steps, plus an honest caveats table separating Plex-only companions from the +ones that work unchanged (per the compatibility list above). Note that Jellyfin has no +claim token and its web UI is on `:8096`. + +### 5. `CLAUDE.md` — one note + +A line under the media-server notes recording that Jellyfin is an optional commented +swap and which companions are Plex-bound, so this isn't rediscovered later. + +## Verification + +- Copy the compose file to a scratch location, uncomment the Jellyfin block (and comment + Plex), and run `docker compose config` with placeholder env to prove the YAML is valid + when enabled. +- Run `docker compose config` on the real file to confirm the default (Plex) path still + validates. +- In `dashboard/`: `npm ci && npm run typecheck && npm run lint && npm test && npm run build`. + +## Out of scope + +- Jellyfin-native companion services (Jellystat, Jellyseerr, Seerr backend reconfig). +- Making any Plex-only companion work against Jellyfin. +- Data migration between Plex and Jellyfin. From 7a444e2613d1e99cac728f997c17bf5765876d2d Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Thu, 6 Aug 2026 14:39:26 -0400 Subject: [PATCH 02/14] docs: implementation plan for Jellyfin variant (#56) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-08-06-jellyfin-variant.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-jellyfin-variant.md diff --git a/docs/superpowers/plans/2026-08-06-jellyfin-variant.md b/docs/superpowers/plans/2026-08-06-jellyfin-variant.md new file mode 100644 index 0000000..f84f1c9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-jellyfin-variant.md @@ -0,0 +1,312 @@ +# Jellyfin Variant Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Offer Jellyfin as a commented-out, drop-in alternative to Plex, with the dashboard catalog aware of it and the docs honest about which companions are Plex-only. + +**Architecture:** Add a fully-formed but commented `jellyfin:` service under `plex:` in `docker-compose.yml`; the user swaps by commenting Plex and uncommenting Jellyfin. Add a matching entry to the dashboard's static service catalog so the panel self-heals when enabled. Document the swap and its caveats in the README, `.env.example`, and `CLAUDE.md`. + +**Tech Stack:** Docker Compose, linuxserver/jellyfin image, TypeScript (Fastify BFF service catalog), Markdown docs. + +## Global Constraints + +- **Never remove the `image:` line** from the dashboard service in favour of `build:` alone (public repo must pull, not build on first `up`). +- **`docker compose config` fails on `.env.example` blanks** for `${VAR:?...}` vars — validate by copying `.env.example` to `.env` and filling throwaway values for `DB_PASSWORD`, `JWT_SECRET`, `COOKIE_SECRET`, `OPENVPN_PROVIDER`, `OPENVPN_CONFIG`, `OPENVPN_USERNAME`, `OPENVPN_PASSWORD`, exactly as `.github/workflows/compose-validate.yml` does. +- **A dashboard `container` field must equal `container_name` in compose exactly**, or health lookups silently report `absent`. +- **No new consumed env var** — every var in `.env.example` must be consumed by compose; a var used only by a commented service would violate this, so Jellyfin gets a comment, not a new var. +- **Jellyfin has no claim token** — do not add a `PLEX_CLAIM` analogue. +- **Self-hosted media stacks may have no outbound internet** — do not introduce CDN/remote asset dependencies. + +--- + +### Task 1: Commented-out Jellyfin service in docker-compose.yml + +**Files:** +- Modify: `docker-compose.yml` (insert between the `plex:` service end at line 17 and the `# ============ MONITORING ============` header at line 19) + +**Interfaces:** +- Consumes: existing `${PUID}`, `${PGID}`, `${TZ}`, `${USERDIR}` env vars. +- Produces: a container named `jellyfin` (relied on by Task 2's catalog entry) on host port `8096`. + +- [ ] **Step 1: Insert the commented Jellyfin block** + +Insert the following immediately after the `plex:` service's `restart: unless-stopped` line (line 17), before the blank line and the `# ============ MONITORING ============` header: + +```yaml + + # ---- Optional: Jellyfin (Plex alternative) ---- + # Jellyfin is a free, fully open-source media server with no paid tier. To use + # it INSTEAD of Plex: comment out the entire `plex:` service above, then + # uncomment the `jellyfin:` service below and run `docker compose up -d`. + # Jellyfin's web UI is on http://:8096 and needs no claim token. + # Heads up: Tautulli, Watchlistarr, Kometa, and Maintainerr are Plex-only and + # will NOT work against Jellyfin — see the README "Using Jellyfin instead of + # Plex" section for the full compatibility list. + # jellyfin: + # container_name: jellyfin + # image: linuxserver/jellyfin + # network_mode: "host" + # environment: + # - PUID=${PUID} + # - PGID=${PGID} + # - TZ=${TZ} + # volumes: + # - ${USERDIR}/jellyfin/config:/config + # - ${USERDIR}/plex/media:/media + # restart: unless-stopped +``` + +- [ ] **Step 2: Verify the default (Plex) file still validates** + +```bash +cp .env.example .env +{ + echo "DB_PASSWORD=ci-validation" + echo "JWT_SECRET=ci-validation" + echo "COOKIE_SECRET=ci-validation" + echo "OPENVPN_PROVIDER=ci-validation" + echo "OPENVPN_CONFIG=ci-validation" + echo "OPENVPN_USERNAME=ci-validation" + echo "OPENVPN_PASSWORD=ci-validation" +} >> .env +docker compose config --quiet && echo "DEFAULT OK" +``` +Expected: prints `DEFAULT OK` (the commented Jellyfin block is invisible to the parser; Plex is unchanged). + +- [ ] **Step 3: Verify the Jellyfin block is valid YAML when enabled** + +Prove the block parses when a user uncomments it, using a scratch copy so the repo file is untouched: + +```bash +SCRATCH="/tmp/claude-1000/-home-ongo-Desktop-projects-AutoPlexx/455cc4a1-500d-4c6a-8a27-2296ec5f36d5/scratchpad" +mkdir -p "$SCRATCH" +cp docker-compose.yml "$SCRATCH/docker-compose.jellyfin.yml" +# Uncomment ONLY the jellyfin service lines (the `# ` / `# jellyfin:` forms), +# leaving the `# ----`/prose comment lines alone. +sed -i -E 's/^ # (jellyfin:)/ \1/; s/^ # (.*)$/ \1/' "$SCRATCH/docker-compose.jellyfin.yml" +docker compose -f "$SCRATCH/docker-compose.jellyfin.yml" --env-file .env config --quiet && echo "JELLYFIN BLOCK OK" +``` +Expected: prints `JELLYFIN BLOCK OK`. If it fails, the indentation in the commented block is wrong — fix Step 1 and re-run. + +- [ ] **Step 4: Clean up validation artifacts** + +```bash +rm -f .env +``` +(The scratch copy lives outside the repo and needs no cleanup for git.) + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat: add Jellyfin as a commented-out Plex alternative (#56)" +``` + +--- + +### Task 2: Jellyfin entry in the dashboard service catalog + +**Files:** +- Modify: `dashboard/server/src/services.ts` (MEDIA section, after the `plex` entry at lines 43-52) + +**Interfaces:** +- Consumes: the `jellyfin` container name produced by Task 1; the existing `ServiceDef` shape and `Hue` / `ServiceGroup` types. +- Produces: a `SERVICES` entry with `id: 'jellyfin'`, `container: 'jellyfin'`, `port: 8096`. + +- [ ] **Step 1: Add the Jellyfin catalog entry** + +In `dashboard/server/src/services.ts`, immediately after the closing `},` of the `plex` entry (line 52) and before the `seerr` entry, insert: + +```ts + { + id: 'jellyfin', + name: 'Jellyfin', + mono: 'JF', + container: 'jellyfin', + group: 'media', + hue: 'violet', + port: 8096, + blurb: 'Alternative media server', + }, +``` + +- [ ] **Step 2: Run the full dashboard verification** + +```bash +cd dashboard +npm ci && npm run typecheck && npm run lint && npm test && npm run build +``` +Expected: all four stages pass. The new entry is plain data conforming to `ServiceDef`, so typecheck/lint should be clean and existing tests unaffected. + +- [ ] **Step 3: Commit** + +```bash +cd .. +git add dashboard/server/src/services.ts +git commit -m "feat(dashboard): add Jellyfin to the service catalog (#56)" +``` + +--- + +### Task 3: Documentation — README, .env.example, CLAUDE.md + +**Files:** +- Modify: `README.md` (Media Server table at lines 132-138) +- Modify: `.env.example` (Plex section at lines 14-17) +- Modify: `CLAUDE.md` (media-server architecture notes) + +**Interfaces:** +- Consumes: the swap mechanism and container from Task 1. +- Produces: user-facing docs; no code interface. + +- [ ] **Step 1: Add the README "Using Jellyfin instead of Plex" subsection** + +In `README.md`, after the Kometa paragraph at line 138 (and before `### Content Management` at line 140), insert: + +```markdown + +
+Using Jellyfin instead of Plex + +Plex has moved features behind Plex Pass over time. If you'd rather run +[Jellyfin](https://jellyfin.org/) — free and fully open-source, no paid tier — the +stack ships a ready-to-use Jellyfin service, commented out in `docker-compose.yml`. + +To switch: + +1. In `docker-compose.yml`, comment out the entire `plex:` service. +2. Uncomment the `jellyfin:` service directly below it. +3. `docker compose up -d`. Jellyfin's web UI is at `http://:8096` — no claim token needed. + +**What still works, and what doesn't.** Jellyfin serves the same media library, but +several companions in this stack talk to Plex's API specifically: + +| Works with Jellyfin as-is | Plex-only (won't work against Jellyfin) | +|---------------------------|------------------------------------------| +| Radarr, Sonarr, Prowlarr, Bazarr | Tautulli (Plex analytics) | +| Transmission | Watchlistarr (syncs the *Plex* watchlist) | +| Seerr (supports a Jellyfin backend) | Kometa / Plex Meta Manager | +| | Maintainerr | + +Because the dashboard's now-playing and poster panels read through Tautulli, those +panels are Plex-bound too. Jellyfin-native replacements (e.g. Jellystat) are a possible +future addition but aren't wired up here yet. + +
+``` + +- [ ] **Step 2: Add the Jellyfin pointer to .env.example** + +In `.env.example`, replace the Plex section header comment (line 14, `# ============ Plex ============`) block so it notes the Jellyfin alternative without adding a variable. Change: + +``` +# ============ Plex ============ +# Obtain immediately before `docker compose up -d`. Claim tokens expire in +# roughly 4 minutes. See https://www.plex.tv/claim +PLEX_CLAIM= +``` + +to: + +``` +# ============ Plex ============ +# Obtain immediately before `docker compose up -d`. Claim tokens expire in +# roughly 4 minutes. See https://www.plex.tv/claim +# Prefer Jellyfin? It needs no token — see the commented `jellyfin:` service in +# docker-compose.yml and "Using Jellyfin instead of Plex" in the README. +PLEX_CLAIM= +``` + +- [ ] **Step 3: Add the CLAUDE.md note** + +In `CLAUDE.md`, under the "Architecture notes that aren't obvious from a glance" area near the Plex/media discussion, add a new bolded note paragraph: + +```markdown +**Jellyfin is an optional, commented-out swap for Plex.** `docker-compose.yml` carries a +fully-formed but commented `jellyfin:` service (host network, port `8096`, no claim +token) directly under `plex:`; the intended workflow is to comment out one and uncomment +the other, per issue #56. The dashboard catalog (`services.ts`) already lists Jellyfin so +its panel self-heals when enabled. Tautulli, Watchlistarr, Kometa, and Maintainerr are +Plex-API-specific and do NOT work against Jellyfin — and since the dashboard's now-playing +and poster panels read through Tautulli, those are Plex-bound too. Wiring Jellyfin-native +companions is deliberately left as a follow-up. +``` + +- [ ] **Step 4: Verify docs render / no broken structure** + +```bash +grep -n "Using Jellyfin instead of Plex" README.md +grep -n "jellyfin:" CLAUDE.md +grep -n "Prefer Jellyfin" .env.example +``` +Expected: each grep returns a match, confirming the three edits landed. + +- [ ] **Step 5: Commit** + +```bash +git add README.md .env.example CLAUDE.md +git commit -m "docs: document Jellyfin-instead-of-Plex swap and caveats (#56)" +``` + +--- + +### Task 4: Open the pull request + +**Files:** none (git/gh only). + +- [ ] **Step 1: Push the branch** + +```bash +git push -u origin feat/jellyfin-variant +``` + +- [ ] **Step 2: Open the PR closing issue #56** + +```bash +gh pr create --base main --head feat/jellyfin-variant \ + --title "Add Jellyfin as a commented-out Plex alternative" \ + --body "$(cat <<'EOF' +Closes #56. + +Adds Jellyfin as a drop-in, opt-in alternative to Plex without changing the default +experience. + +## What's in here +- **docker-compose.yml** — a fully-formed but commented `jellyfin:` service under `plex:` + (linuxserver/jellyfin, host network, `:8096`, no claim token). Swap by commenting Plex + and uncommenting Jellyfin. +- **dashboard** — a Jellyfin entry in the service catalog so its panel self-heals the + moment the service is enabled; renders `absent` for Plex users. +- **docs** — README "Using Jellyfin instead of Plex" section with an honest + works/doesn't-work table, a `.env.example` pointer, and a `CLAUDE.md` note. + +## Explicitly out of scope +Wiring Jellyfin-native companions (Jellystat, Seerr backend reconfig). Tautulli, +Watchlistarr, Kometa, and Maintainerr remain Plex-only; the README says so. + +## Verification +- `docker compose config --quiet` passes on the default file, and on a scratch copy with + the Jellyfin block uncommented. +- `npm ci && npm run typecheck && npm run lint && npm test && npm run build` pass in `dashboard/`. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Spec §1 (commented compose service) → Task 1. ✓ +- Spec §2 (.env.example comment, no new var) → Task 3 Step 2. ✓ +- Spec §3 (dashboard catalog entry) → Task 2. ✓ +- Spec §4 (README section + caveats table) → Task 3 Step 1. ✓ +- Spec §5 (CLAUDE.md note) → Task 3 Step 3. ✓ +- Spec verification (compose config both ways + dashboard build) → Task 1 Steps 2-3, Task 2 Step 2. ✓ +- Spec branch/PR (closes #56) → Task 4. ✓ + +**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code/edit step carries literal content. ✓ + +**Type consistency:** The catalog entry uses only existing `ServiceDef` fields with valid `Hue` (`'violet'`) and `ServiceGroup` (`'media'`) values; `container: 'jellyfin'` matches the `container_name: jellyfin` produced in Task 1. ✓ From ad5393003b949767fedbbe5c3e6ac3fa0c31b861 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Thu, 6 Aug 2026 15:38:19 -0400 Subject: [PATCH 03/14] feat: add Jellyfin as a commented-out Plex alternative (#56) --- docker-compose.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 5849269..751e30b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,27 @@ services: - ${USERDIR}/plex/media:/media restart: unless-stopped + # ---- Optional: Jellyfin (Plex alternative) ---- + # Jellyfin is a free, fully open-source media server with no paid tier. To use + # it INSTEAD of Plex: comment out the entire `plex:` service above, then + # uncomment the `jellyfin:` service below and run `docker compose up -d`. + # Jellyfin's web UI is on http://:8096 and needs no claim token. + # Heads up: Tautulli, Watchlistarr, Kometa, and Maintainerr are Plex-only and + # will NOT work against Jellyfin — see the README "Using Jellyfin instead of + # Plex" section for the full compatibility list. + # jellyfin: + # container_name: jellyfin + # image: linuxserver/jellyfin + # network_mode: "host" + # environment: + # - PUID=${PUID} + # - PGID=${PGID} + # - TZ=${TZ} + # volumes: + # - ${USERDIR}/jellyfin/config:/config + # - ${USERDIR}/plex/media:/media + # restart: unless-stopped + # ============ MONITORING ============ tautulli: container_name: tautulli From e79db32adfc502ad4a275b257c42f8b44a1bd39e Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Thu, 6 Aug 2026 16:09:12 -0400 Subject: [PATCH 04/14] feat(dashboard): add Jellyfin to the service catalog (#56) --- dashboard/server/src/services.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dashboard/server/src/services.ts b/dashboard/server/src/services.ts index 2701880..463d56d 100644 --- a/dashboard/server/src/services.ts +++ b/dashboard/server/src/services.ts @@ -50,6 +50,16 @@ export const SERVICES: readonly ServiceDef[] = [ port: 32400, blurb: 'Central media server', }, + { + id: 'jellyfin', + name: 'Jellyfin', + mono: 'JF', + container: 'jellyfin', + group: 'media', + hue: 'violet', + port: 8096, + blurb: 'Alternative media server', + }, { id: 'seerr', name: 'Seerr', From 43a2ad9689984698d33c487f30d703db82d3cd53 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Thu, 6 Aug 2026 16:24:02 -0400 Subject: [PATCH 05/14] docs: document Jellyfin-instead-of-Plex swap and caveats (#56) --- .env.example | 2 ++ CLAUDE.md | 9 +++++++++ README.md | 29 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/.env.example b/.env.example index 2a90a19..a218144 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ USERDIR=/home/username # ============ Plex ============ # Obtain immediately before `docker compose up -d`. Claim tokens expire in # roughly 4 minutes. See https://www.plex.tv/claim +# Prefer Jellyfin? It needs no token — see the commented `jellyfin:` service in +# docker-compose.yml and "Using Jellyfin instead of Plex" in the README. PLEX_CLAIM= # ============ Transmission / OpenVPN (required — stack won't start if blank) ============ diff --git a/CLAUDE.md b/CLAUDE.md index 236a470..bb9b88a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,15 @@ Radarr and Sonarr are deliberately on both `media_network` (so Seerr, Prowlarr, **Plex claim tokens expire in ~4 minutes.** `PLEX_CLAIM` must be set in `.env` immediately before `docker compose up -d` on first run. If the user reports a Plex auth issue on first boot, this is almost always why. +**Jellyfin is an optional, commented-out swap for Plex.** `docker-compose.yml` carries a +fully-formed but commented `jellyfin:` service (host network, port `8096`, no claim +token) directly under `plex:`; the intended workflow is to comment out one and uncomment +the other, per issue #56. The dashboard catalog (`services.ts`) already lists Jellyfin so +its panel self-heals when enabled. Tautulli, Watchlistarr, Kometa, and Maintainerr are +Plex-API-specific and do NOT work against Jellyfin — and since the dashboard's now-playing +and poster panels read through Tautulli, those are Plex-bound too. Wiring Jellyfin-native +companions is deliberately left as a follow-up. + ## Kometa (plex-meta-manager) layout The mounted config directory is `plex-meta-manager/config/`. Its structure is referenced explicitly by `config.yml`: diff --git a/README.md b/README.md index c2484e7..e909bc7 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,35 @@ Suggested captures: Plex web UI, Seerr discover page, Radarr/Sonarr libraries, T A ready-to-use [Kometa](https://kometa.wiki/) (Plex Meta Manager) configuration is included for automated collections and overlays, but Kometa itself is not part of `docker-compose.yml` — see [Kometa Configuration](#kometa-configuration) for how to run it. +
+Using Jellyfin instead of Plex + +Plex has moved features behind Plex Pass over time. If you'd rather run +[Jellyfin](https://jellyfin.org/) — free and fully open-source, no paid tier — the +stack ships a ready-to-use Jellyfin service, commented out in `docker-compose.yml`. + +To switch: + +1. In `docker-compose.yml`, comment out the entire `plex:` service. +2. Uncomment the `jellyfin:` service directly below it. +3. `docker compose up -d`. Jellyfin's web UI is at `http://:8096` — no claim token needed. + +**What still works, and what doesn't.** Jellyfin serves the same media library, but +several companions in this stack talk to Plex's API specifically: + +| Works with Jellyfin as-is | Plex-only (won't work against Jellyfin) | +|---------------------------|------------------------------------------| +| Radarr, Sonarr, Prowlarr, Bazarr | Tautulli (Plex analytics) | +| Transmission | Watchlistarr (syncs the *Plex* watchlist) | +| Seerr (supports a Jellyfin backend) | Kometa / Plex Meta Manager | +| | Maintainerr | + +Because the dashboard's now-playing and poster panels read through Tautulli, those +panels are Plex-bound too. Jellyfin-native replacements (e.g. Jellystat) are a possible +future addition but aren't wired up here yet. + +
+ ### Content Management | Service | Description | Port | From 7935fa8e6fa1a90760f9d99f579483302f4eb96b Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Fri, 7 Aug 2026 09:35:45 -0400 Subject: [PATCH 06/14] fix: unblock the Jellyfin swap and document it properly (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tautulli` declared `depends_on: - plex`, and compose rejects a project whose depends_on names an undefined service. So step 1 of the documented swap — comment out `plex:` — made every compose command fail: service "tautulli" depends on undefined service "plex": invalid compose project Not a degraded Tautulli: `config`, `up -d` and `down` all failed, for all 25 services. The dependency bought nothing anyway, since Plex is host-network and Tautulli is on monitoring_network, so compose can neither link nor meaningfully order them. Verified by reconstructing the swap: `docker compose config` went from exit 1 to exit 0, resolving 27 services with `jellyfin` and no `plex`. Also corrects the compatibility table, which listed Seerr as working "as-is". Seerr does support a Jellyfin backend, but the media server is chosen during its setup and doesn't follow the compose swap — so a user who onboarded against Plex would have had requests silently going nowhere. Surfaces Jellyfin in the README beyond the one collapsed block: a badge, a Media Server table row, a Why bullet, and a note on the claim-token prerequisite. The compatibility section is promoted from
to a real heading because GitHub generates anchors only from headings, so the new links to #using-jellyfin-instead-of-plex would otherwise be dead. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 14 ++++++++++++-- README.md | 22 ++++++++++++++++------ docker-compose.yml | 7 +++++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb9b88a..648a254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,8 +77,18 @@ token) directly under `plex:`; the intended workflow is to comment out one and u the other, per issue #56. The dashboard catalog (`services.ts`) already lists Jellyfin so its panel self-heals when enabled. Tautulli, Watchlistarr, Kometa, and Maintainerr are Plex-API-specific and do NOT work against Jellyfin — and since the dashboard's now-playing -and poster panels read through Tautulli, those are Plex-bound too. Wiring Jellyfin-native -companions is deliberately left as a follow-up. +and poster panels read through Tautulli, those are Plex-bound too. Seerr does support a +Jellyfin backend, but only after the media server is re-pointed in its own settings — the +compose swap alone doesn't move it. Wiring Jellyfin-native companions is deliberately left +as a follow-up. + +**Nothing may `depends_on: plex`.** Compose rejects a project whose `depends_on` names an +undefined service, so a single such reference turns the documented Jellyfin swap into a +hard failure for all 25 services — `config`, `up`, and even `down`. Tautulli used to carry +one; it was removed. It bought nothing anyway, since Plex is host-network and its peers are +on bridge networks, so compose can neither link nor meaningfully order them. The same +reasoning applies to any future optional service: an optional service must have no +dependents. ## Kometa (plex-meta-manager) layout diff --git a/README.md b/README.md index e909bc7..059b3c0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ [![Issues](https://img.shields.io/github/issues/joshdev8/AutoPlexx?style=flat-square)](https://github.com/joshdev8/AutoPlexx/issues) [![Docker Compose](https://img.shields.io/badge/Docker_Compose-v2+-2496ED?style=flat-square&logo=docker&logoColor=white)](https://docs.docker.com/compose/) [![Plex](https://img.shields.io/badge/Plex-EBAF00?style=flat-square&logo=plex&logoColor=black)](https://www.plex.tv/) +[![Jellyfin](https://img.shields.io/badge/Jellyfin-optional-00A4DC?style=flat-square&logo=jellyfin&logoColor=white)](#using-jellyfin-instead-of-plex) @@ -24,11 +25,12 @@ A complete, opinionated [Plex Media Server](https://www.plex.tv/) stack delivere - **Pre-built Kometa config included** — IMDb Top 250 / Trakt / streaming-service collections, daily rotating playlists, and resolution/HDR overlays are ready to run, not a blank YAML you fill in over weeks. - **Network-isolated by design** — four separate Docker networks split streaming, request flow, downloading, and monitoring so a misbehaving service can't talk to the rest. - **Stream analytics in the box** — Tracearr ships built-in for concurrent-stream monitoring, geolocation, and account-sharing detection alongside Tautulli's usage reporting. +- **Not locked to Plex** — a ready-to-run [Jellyfin](https://jellyfin.org/) service ships commented out, so swapping the media server is uncommenting a block rather than rebuilding the stack. [What that costs you](#using-jellyfin-instead-of-plex) is documented up front. ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) (v2+) -- A Plex account and a [claim token](https://www.plex.tv/claim) — generate this **immediately before** your first `docker compose up`; claim tokens expire roughly 4 minutes after they're issued +- A Plex account and a [claim token](https://www.plex.tv/claim) — generate this **immediately before** your first `docker compose up`; claim tokens expire roughly 4 minutes after they're issued. Not needed if you're [running Jellyfin instead](#using-jellyfin-instead-of-plex) - Values for `DB_PASSWORD`, `JWT_SECRET`, and `COOKIE_SECRET` in `.env` — Tracearr refuses to start without them and will fail the whole stack's `up` command - OpenVPN credentials from a [supported VPN provider](https://haugene.github.io/docker-transmission-openvpn/supported-providers/) (`OPENVPN_PROVIDER`, `OPENVPN_CONFIG`, `OPENVPN_USERNAME`, `OPENVPN_PASSWORD`) — Transmission tunnels all traffic through OpenVPN and won't start without them. See [Transmission VPN setup](#transmission-vpn-setup) for details @@ -134,11 +136,15 @@ Suggested captures: Plex web UI, Seerr discover page, Radarr/Sonarr libraries, T | Service | Description | Port | |---------|-------------|------| | [Plex](https://www.plex.tv/) | Central media server | `32400` (host network) | +| [Jellyfin](https://jellyfin.org/) | Open-source media server — optional drop-in *replacement* for Plex, shipped commented out | `8096` (host network) | + +Pick one media server, not both — they'd index the same library twice. Plex is the +default and everything in the stack is wired for it; see [Using Jellyfin instead of +Plex](#using-jellyfin-instead-of-plex) below for the swap and what it costs you. A ready-to-use [Kometa](https://kometa.wiki/) (Plex Meta Manager) configuration is included for automated collections and overlays, but Kometa itself is not part of `docker-compose.yml` — see [Kometa Configuration](#kometa-configuration) for how to run it. -
-Using Jellyfin instead of Plex +### Using Jellyfin instead of Plex Plex has moved features behind Plex Pass over time. If you'd rather run [Jellyfin](https://jellyfin.org/) — free and fully open-source, no paid tier — the @@ -157,15 +163,19 @@ several companions in this stack talk to Plex's API specifically: |---------------------------|------------------------------------------| | Radarr, Sonarr, Prowlarr, Bazarr | Tautulli (Plex analytics) | | Transmission | Watchlistarr (syncs the *Plex* watchlist) | -| Seerr (supports a Jellyfin backend) | Kometa / Plex Meta Manager | +| | Kometa / Plex Meta Manager | | | Maintainerr | +**Seerr needs reconfiguring, not replacing.** Seerr does support a Jellyfin backend, +but the media server is chosen during its setup and doesn't follow the compose swap. If +you already onboarded Seerr against Plex, re-point it at Jellyfin in **Settings → +Media Server** — otherwise approved requests keep going to a Plex server that is no +longer running, with no error to tell you so. + Because the dashboard's now-playing and poster panels read through Tautulli, those panels are Plex-bound too. Jellyfin-native replacements (e.g. Jellystat) are a possible future addition but aren't wired up here yet. -
- ### Content Management | Service | Description | Port | diff --git a/docker-compose.yml b/docker-compose.yml index 751e30b..3d0344e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,8 +43,11 @@ services: image: tautulli/tautulli networks: - monitoring_network - depends_on: - - plex + # No `depends_on: plex` on purpose. Plex is host-network and Tautulli is on + # monitoring_network, so compose can't order or link them anyway — but a + # depends_on would make the whole project invalid the moment someone + # comments out `plex:` to run Jellyfin instead. Tautulli starts fine against + # an unreachable Plex and reconnects on its own. ports: - "8181:8181" environment: From e9bbb813157fe71656d3e434754753ed9e950a89 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Fri, 7 Aug 2026 09:36:33 -0400 Subject: [PATCH 07/14] fix(dashboard): don't link to services that aren't installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog lists Jellyfin, which ships commented out of compose, so every default (Plex) install rendered a Media tile reading "Not installed" whose Open affordance linked to :8096 and connection-refused. Three of the four link sites decided openability from `port === null` alone, which can't distinguish "publishes no UI" from "isn't here". CommandSearch had it worse than the tiles: an absent service was a keyboard-navigable result, so Enter opened a dead tab. Adds `launchUrl()` as the single place that decision is made, returning null for both reasons, and routes Launcher, Sidebar, CommandSearch and Header's Request button through it. Header already had the check inline and now shares the helper. `down` still yields a URL on purpose — the container exists and the user may be about to start it. Only `absent` suppresses the link. CommandSearch's `uiless` bucket becomes `unopenable` since it now holds two different reasons, and its footer reads "nothing to open" rather than "no web UI", which would have been wrong for an uninstalled service that does publish a port. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 7 ++++ dashboard/web/src/app/Header.tsx | 7 ++-- dashboard/web/src/app/Sidebar.tsx | 4 +- .../web/src/components/CommandSearch.tsx | 38 +++++++++++-------- dashboard/web/src/types.ts | 18 +++++++++ dashboard/web/src/views/Launcher.tsx | 6 +-- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 648a254..ed83e5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,13 @@ on bridge networks, so compose can neither link nor meaningfully order them. The reasoning applies to any future optional service: an optional service must have no dependents. +**The dashboard catalog lists services that may not exist**, so a link to one must be +suppressed rather than drawn dead. `launchUrl()` in `web/src/types.ts` is the single place +that decides — it returns `null` for both "no UI port" and `state === 'absent'`. All four +link sites (`Launcher`, `Sidebar`, `CommandSearch`, `Header`'s Request button) go through +it; don't reintroduce a bare `serviceUrl(service.port)` at a call site. `down` deliberately +still yields a URL, because the container exists and the user may be about to start it. + ## Kometa (plex-meta-manager) layout The mounted config directory is `plex-meta-manager/config/`. Its structure is referenced explicitly by `config.yml`: diff --git a/dashboard/web/src/app/Header.tsx b/dashboard/web/src/app/Header.tsx index 6653e67..1098c30 100644 --- a/dashboard/web/src/app/Header.tsx +++ b/dashboard/web/src/app/Header.tsx @@ -2,7 +2,7 @@ import { ArrowUpRight, Moon, Plus, Sun } from '@phosphor-icons/react'; import { CommandSearch } from '../components/CommandSearch'; import { Notifications } from '../components/Notifications'; -import { serviceUrl, type ServiceGroup, type ServiceStatus } from '../types'; +import { launchUrl, type ServiceGroup, type ServiceStatus } from '../types'; import type { Alert } from '../alerts'; import type { Theme } from '../hooks/useTheme'; @@ -94,7 +94,8 @@ function RequestButton({ services }: { services: ServiceStatus[] }) { // Not in the catalog, absent from the host, or published without a UI port — // in each case there is nothing to link to, so no control is shown. - if (!seerr || seerr.state === 'absent' || seerr.port === null) return null; + const href = seerr ? launchUrl(seerr) : null; + if (!seerr || href === null) return null; if (seerr.state === 'down') { return ( @@ -108,7 +109,7 @@ function RequestButton({ services }: { services: ServiceStatus[] }) { return ( diff --git a/dashboard/web/src/components/CommandSearch.tsx b/dashboard/web/src/components/CommandSearch.tsx index e54adcf..dc87f19 100644 --- a/dashboard/web/src/components/CommandSearch.tsx +++ b/dashboard/web/src/components/CommandSearch.tsx @@ -4,7 +4,7 @@ import { MagnifyingGlass } from '@phosphor-icons/react'; import { ServiceIcon } from './ServiceIcon'; import { StatusDot } from './StatusDot'; import { useDismissable } from '../hooks/useDismissable'; -import { serviceUrl, type ServiceGroup, type ServiceStatus } from '../types'; +import { launchUrl, type ServiceGroup, type ServiceStatus } from '../types'; interface Props { services: ServiceStatus[]; @@ -13,16 +13,17 @@ interface Props { const MAX_RESULTS = 7; -/** A service is openable when it publishes a web UI. */ +/** A service is openable when it publishes a web UI and is actually installed. */ interface Openable extends ServiceStatus { port: number; + href: string; } export interface Matches { /** Services that can actually be opened — the navigable results. */ openable: Openable[]; - /** Matched services with no web UI, named but not offered as results. */ - uiless: ServiceStatus[]; + /** Matched services with nothing to open, named but not offered as results. */ + unopenable: ServiceStatus[]; } /** @@ -54,7 +55,7 @@ export function match( rawQuery: string, ): Matches { const query = rawQuery.trim().toLowerCase(); - if (query === '') return { openable: [], uiless: [] }; + if (query === '') return { openable: [], unopenable: [] }; const visible = new Set(groups.map((group) => group.id)); const ranked = services @@ -65,21 +66,26 @@ export function match( }) .sort((a, b) => a.rank - b.rank || a.service.name.localeCompare(b.service.name)); + const withHref = ranked.map(({ service }) => ({ service, href: launchUrl(service) })); + return { - openable: ranked - .flatMap(({ service }) => (service.port === null ? [] : [{ ...service, port: service.port }])) + openable: withHref + .flatMap(({ service, href }) => + href === null || service.port === null ? [] : [{ ...service, port: service.port, href }], + ) .slice(0, MAX_RESULTS), - uiless: ranked.flatMap(({ service }) => (service.port === null ? [service] : [])), + unopenable: withHref.flatMap(({ service, href }) => (href === null ? [service] : [])), }; } /** * The header's service search. * - * The one action a result can take is "open this service", so services without - * a web UI aren't offered as results — they'd be rows that do nothing on Enter. - * They're still named underneath when they match, because a user searching - * "watchtower" deserves better than an empty menu. + * The one action a result can take is "open this service", so services with + * nothing to open — no web UI, or not installed — aren't offered as results; + * they'd be rows that do nothing on Enter, or worse, open a dead tab. They're + * still named underneath when they match, because a user searching "watchtower" + * deserves better than an empty menu. */ export function CommandSearch({ services, groups }: Props) { const [query, setQuery] = useState(''); @@ -91,7 +97,7 @@ export function CommandSearch({ services, groups }: Props) { const close = () => setOpen(false); const wrapRef = useDismissable(open, close); - const { openable, uiless } = useMemo(() => match(services, groups, query), [services, groups, query]); + const { openable, unopenable } = useMemo(() => match(services, groups, query), [services, groups, query]); // Clamp rather than reset, so results narrowing under the cursor doesn't // leave the highlight pointing past the end of the list. @@ -121,7 +127,7 @@ export function CommandSearch({ services, groups }: Props) { }, []); const openService = (service: Openable) => { - window.open(serviceUrl(service.port), '_blank', 'noopener,noreferrer'); + window.open(service.href, '_blank', 'noopener,noreferrer'); setQuery(''); close(); }; @@ -231,7 +237,7 @@ export function CommandSearch({ services, groups }: Props) { )} - {uiless.length > 0 && ( + {unopenable.length > 0 && (
- Also matched, no web UI: {uiless.map((service) => service.name).join(', ')} + Also matched, nothing to open: {unopenable.map((service) => service.name).join(', ')}
)} diff --git a/dashboard/web/src/types.ts b/dashboard/web/src/types.ts index f1bc412..92df0dd 100644 --- a/dashboard/web/src/types.ts +++ b/dashboard/web/src/types.ts @@ -62,6 +62,24 @@ export function serviceUrl(port: number): string { return `${window.location.protocol}//${window.location.hostname}:${port}`; } +/** + * Where a service's web UI can be opened, or `null` when there's nothing to + * open. Two reasons for `null`, and both must be honoured everywhere a link is + * drawn: + * + * - the service publishes no UI port (Watchtower, the socket proxy); + * - the service isn't installed. The catalog lists services that are optional + * in `docker-compose.yml` — Jellyfin ships commented out — and linking to a + * port nothing is listening on is worse than showing no link at all. + * + * `down` deliberately still yields a URL: the container exists and the user may + * be about to start it. + */ +export function launchUrl(service: Pick): string | null { + if (service.port === null || service.state === 'absent') return null; + return serviceUrl(service.port); +} + // ---- Widget payloads (mirrors the server's source modules) ------------------ /** diff --git a/dashboard/web/src/views/Launcher.tsx b/dashboard/web/src/views/Launcher.tsx index bb33260..a1e7e44 100644 --- a/dashboard/web/src/views/Launcher.tsx +++ b/dashboard/web/src/views/Launcher.tsx @@ -2,7 +2,7 @@ import { ArrowUpRight } from '@phosphor-icons/react'; import { ServiceIcon } from '../components/ServiceIcon'; import { StatusDot } from '../components/StatusDot'; -import { HUE_VAR, STATE_LABEL, serviceUrl, type ServiceGroup, type ServiceStatus } from '../types'; +import { HUE_VAR, STATE_LABEL, launchUrl, type ServiceGroup, type ServiceStatus } from '../types'; interface Props { services: ServiceStatus[]; @@ -42,9 +42,9 @@ export function Launcher({ services, groups }: Props) { function ServiceTile({ service }: { service: ServiceStatus }) { const color = HUE_VAR[service.hue]; - // Services without a published port have no UI to open, so they render as a + // Services with nothing to open — no UI port, or not installed — render as a // plain card rather than a dead link. - const href = service.port === null ? null : serviceUrl(service.port); + const href = launchUrl(service); const body = ( <> From e2ade2d1e46eeb137057e53a9e9173b24f16292f Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Fri, 7 Aug 2026 10:23:37 -0400 Subject: [PATCH 08/14] fix(dashboard): only suppress links for opt-in optional services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the dashboard against a real host showed the previous commit's rule was too broad. Suppressing every `absent` service assumed absent means "not installed", but a catalog entry also goes absent when its container is simply renamed. On a host running plexms, transmission-vpn and grafana-grafana-1, five services reported absent while up and serving, and lost working launch links. Adds `optional: true` in the catalog, set on plex and jellyfin — the two halves of the swap, where exactly one is uncommented and the other's port genuinely leads nowhere. Only those lose a link when absent. Everything else keeps it: an absent Radarr is a real problem, and hiding its link would hide the problem too. Also fixes a regression this rule introduced. `buildReport` reports every service absent on a cold start with the socket proxy unreachable, so suppression keyed on `absent` alone would strip both media links exactly when the launcher matters most — a dead upstream blanking the page, which this app does not do. `stateKnown` threads the report's `reachable` flag down so absent only counts when Docker was actually reached. Gives the web workspace a test suite; it had no test script at all, so none of this behavior was covered. That meant extracting the pure ranking logic to search.ts, since anything importing the component tree reaches import.meta.glob in ServiceIcon, which node --test cannot evaluate. 50 server + 16 web tests pass. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 27 ++++- dashboard/package-lock.json | 2 + dashboard/server/src/services.ts | 13 +++ dashboard/web/package.json | 5 +- dashboard/web/src/app/App.tsx | 16 ++- dashboard/web/src/app/Header.tsx | 11 +- dashboard/web/src/app/Sidebar.tsx | 10 +- .../web/src/components/CommandSearch.tsx | 79 ++----------- dashboard/web/src/search.test.ts | 108 ++++++++++++++++++ dashboard/web/src/search.ts | 85 ++++++++++++++ dashboard/web/src/types.test.ts | 57 +++++++++ dashboard/web/src/types.ts | 30 ++++- dashboard/web/src/views/Launcher.tsx | 10 +- 13 files changed, 357 insertions(+), 96 deletions(-) create mode 100644 dashboard/web/src/search.test.ts create mode 100644 dashboard/web/src/search.ts create mode 100644 dashboard/web/src/types.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ed83e5a..6dd9460 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,12 +90,27 @@ on bridge networks, so compose can neither link nor meaningfully order them. The reasoning applies to any future optional service: an optional service must have no dependents. -**The dashboard catalog lists services that may not exist**, so a link to one must be -suppressed rather than drawn dead. `launchUrl()` in `web/src/types.ts` is the single place -that decides — it returns `null` for both "no UI port" and `state === 'absent'`. All four -link sites (`Launcher`, `Sidebar`, `CommandSearch`, `Header`'s Request button) go through -it; don't reintroduce a bare `serviceUrl(service.port)` at a call site. `down` deliberately -still yields a URL, because the container exists and the user may be about to start it. +**`launchUrl()` in `web/src/types.ts` is the single place that decides whether a service +link is drawn.** All four link sites (`Launcher`, `Sidebar`, `CommandSearch`, `Header`'s +Request button) go through it; don't reintroduce a bare `serviceUrl(service.port)` at a +call site. It suppresses a link in exactly two cases — no UI port, or an `optional` +service that is `absent` — and the narrowness is deliberate: + +- Only services flagged `optional: true` in `services.ts` (today: `plex` and `jellyfin`, + the two halves of the swap) lose their link when absent. **Don't flag a service compose + always defines.** A catalog entry also goes `absent` when its container is merely + *renamed*, and silently dropping a link that still works hides the mismatch instead of + surfacing it. Verified against a real host running `plexms`/`transmission-vpn`. +- `absent` only counts when the socket proxy was actually reached, which is what the + `stateKnown` prop threads down from `App`. On a cold start with the proxy down, + `buildReport` reports *every* service absent — suppressing then would blank both media + tiles at the worst moment. +- `down` still yields a URL: the container exists and the user may be about to start it. + +**The web workspace has tests** (`npm test --workspace=web`, `node --test` like the +server). Pure logic belongs outside `.tsx` files so it stays testable — `search.ts` was +split out of `CommandSearch.tsx` because anything importing the component tree reaches +`import.meta.glob` in `ServiceIcon`, which plain `node --test` cannot evaluate. ## Kometa (plex-meta-manager) layout diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 5691f94..fc3b406 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -4465,9 +4465,11 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@types/node": "^22.10.2", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.5" } diff --git a/dashboard/server/src/services.ts b/dashboard/server/src/services.ts index 463d56d..e47974f 100644 --- a/dashboard/server/src/services.ts +++ b/dashboard/server/src/services.ts @@ -36,6 +36,17 @@ export interface ServiceDef { port: number | null; /** One-line description, shown under the name in the Launcher. */ blurb: string; + /** + * True when docker-compose.yml may legitimately not define this service, so + * `absent` means "the user chose not to run it" rather than "something is + * wrong". Only these lose their launch link when absent — see `launchUrl` in + * the web app. Set on both halves of the Plex/Jellyfin swap, since exactly + * one of them is uncommented at a time and the other's port leads nowhere. + * + * Do NOT set this on a service compose always defines. An `absent` Radarr + * means a real problem, and hiding its link would hide the problem too. + */ + optional?: boolean; } export const SERVICES: readonly ServiceDef[] = [ @@ -49,6 +60,7 @@ export const SERVICES: readonly ServiceDef[] = [ hue: 'amber', port: 32400, blurb: 'Central media server', + optional: true, }, { id: 'jellyfin', @@ -59,6 +71,7 @@ export const SERVICES: readonly ServiceDef[] = [ hue: 'violet', port: 8096, blurb: 'Alternative media server', + optional: true, }, { id: 'seerr', diff --git a/dashboard/web/package.json b/dashboard/web/package.json index b5e7544..e7f0cd7 100644 --- a/dashboard/web/package.json +++ b/dashboard/web/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --import tsx --test \"src/**/*.test.ts\"" }, "dependencies": { "@fontsource/inter": "^5.1.0", @@ -16,9 +17,11 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@types/node": "^22.10.2", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.5" } diff --git a/dashboard/web/src/app/App.tsx b/dashboard/web/src/app/App.tsx index 4f56719..7983c22 100644 --- a/dashboard/web/src/app/App.tsx +++ b/dashboard/web/src/app/App.tsx @@ -44,6 +44,11 @@ export function App() { const groups = catalog.data?.groups ?? []; const services = health.data?.services ?? catalog.data?.services ?? []; + // Whether an `absent` service really is uninstalled, or whether we simply + // couldn't reach the socket proxy — see `launchUrl`. The catalog fallback + // carries no state at all, so it's unaffected either way. + const stateKnown = health.data?.reachable === true; + const alerts = useMemo( () => deriveAlerts(health.data, integrations.data?.integrations ?? []), [health.data, integrations.data], @@ -75,7 +80,13 @@ export function App() { fontFamily: 'var(--font-body)', }} > - +
setView('setup')} /> @@ -167,7 +179,7 @@ export function App() { Loading services… ) : ( - + ))} {view === 'setup' && ( diff --git a/dashboard/web/src/app/Header.tsx b/dashboard/web/src/app/Header.tsx index 1098c30..b760f13 100644 --- a/dashboard/web/src/app/Header.tsx +++ b/dashboard/web/src/app/Header.tsx @@ -13,6 +13,8 @@ interface Props { onToggleTheme: () => void; services: ServiceStatus[]; groups: readonly { id: ServiceGroup; label: string }[]; + /** Whether container state is trustworthy — see `launchUrl`. */ + stateKnown: boolean; alerts: Alert[]; onOpenSetup: () => void; } @@ -24,6 +26,7 @@ export function Header({ onToggleTheme, services, groups, + stateKnown, alerts, onOpenSetup, }: Props) { @@ -63,8 +66,8 @@ export function Header({
- - + +