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..ff5dfc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,9 @@ Things that aren't obvious: - **`server/src/services.ts` is the single source of truth** for the service list. Its `container` field must match `container_name` in `docker-compose.yml` exactly, or health lookups silently report the service as `absent`. Adding a service to compose means adding it here too. - **Container status comes from `docker-socket-proxy`, never a direct socket mount.** `:ro` on a socket does not make the Docker API read-only — it only affects the file node — so mounting it into the dashboard would be a second root-equivalent exposure alongside Portainer's. The proxy runs with `CONTAINERS=1` and everything else off. If asked to "simplify" by mounting the socket directly, push back. -- **API keys are auto-discovered, not configured.** The dashboard reads each service's own config file (`config.xml` for the \*arrs, `config.ini` for Tautulli, `settings.json` for Seerr) through the read-only `/discover/*` mounts. Resolution order is env var → discovered file → unconfigured. Discovery re-runs at runtime because on a clean install those files don't exist until each service's first boot — so a panel must recover on its own without a dashboard restart. +- **API keys are auto-discovered, not configured.** The dashboard reads each service's own config file through the read-only `/discover/*` mounts. Resolution order is env var → discovered file → unconfigured. Discovery re-runs at runtime because on a clean install those files don't exist until each service's first boot — so a panel must recover on its own without a dashboard restart. + - The formats differ per service and **Bazarr is not an \*arr**: `config.xml` for Sonarr/Radarr/Prowlarr, `config.ini` for Tautulli, `settings.json` for Seerr, and `config/config.yaml` — nested one level *inside* the config dir — for Bazarr, which is Python and never writes a `config.xml` at all. Grouping it with the \*arrs left that integration permanently `waiting` while telling users to start a Bazarr that was already running. + - Bazarr stores an `apikey` under a dozen sections, including `radarr:` and `sonarr:` for the services it talks to. Its own key is `auth.apikey` specifically — a document-wide search for the first `apikey` will eventually hand back another service's credential. - **Nothing may throw on missing configuration.** An unset key or a dead upstream degrades that one panel. One failed integration must never blank the page, and a failed refresh keeps the last good data on screen. Every widget route returns `Result` — either the payload with `available: true`, or `{ available: false, reason, hint }`. Routes return 200 even when the upstream failed; the discriminant is how the UI decides what to render. Wrap source loads in `safely()` from `http.ts` rather than letting them reject. - **A `hint` must name the actual fix.** `hintFor()` in `sources/transmission.ts` is the pattern: a rejected credential and an unreachable host need different advice, and a generic hint sends people looking in the wrong place. - **`web/src/styles/nocturne.css` is a vendored design system** from the issue #48 handoff. Take colors, spacing, radii and shadows from its `var(--*)` tokens rather than hard-coding values. Inter and Phosphor icons are self-hosted on purpose — a self-hosted media stack may have no outbound internet, so don't "optimize" them back to a CDN. @@ -49,7 +51,8 @@ Things that aren't obvious: - `media_network` — seerr, radarr, sonarr, prowlarr, bazarr, flaresolverr, maintainerr, checkrr, dashboard - `download_network` — transmission, watchlistarr, cleanarr, requestrr, decluttarr, radarr, sonarr, dashboard - `tracearr-network` — tracearr, timescale (PostgreSQL), redis -- **host network** — plex only (required for proper streaming/discovery) +- **host network** — plex (required for proper streaming/discovery), or the optional + jellyfin that replaces it; exactly one of the two is uncommented at a time `dashboard` is on all three service networks because it aggregates from all of them. It reaches Plex — which is on the host network — via `host.docker.internal`, hence its `extra_hosts` entry. @@ -71,6 +74,58 @@ 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 swap has to remove the outgoing container first** +(`docker compose rm -sf plex`): compose only manages services it can see, so once `plex:` +is commented out `up -d` leaves the old container running and the user quietly ends up +with two media servers on one library. Advise the removal before the edit, not after. +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. 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. + +**`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 it was actually observed. `isMissing()` is the single + predicate for that question, and the `stateKnown` prop that feeds it comes from the + report's **`statesKnown`, not `reachable`** — the two differ and the difference is the + whole point. A blip after a successful poll keeps the real states and only marks them + stale (`reachable: false`, `statesKnown: true`); only the cold path, where nothing was + ever observed, reports `statesKnown: false`. Keying off `reachable` made every hiccup + briefly re-link every absent optional service. +- `down` still yields a URL: the container exists and the user may be about to start it. +- **A caller that needs "is this service really missing" must call `isMissing()`, not + infer it from a null `launchUrl()`.** Those are different questions: `launchUrl` returns + a URL for an absent *non*-optional service by design. `Header`'s Request button + conflated them once already and rendered a live link to an uninstalled Seerr. + +**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 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..5830259 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,9 +136,51 @@ 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 + +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. `docker compose rm -sf plex` — stop and remove Plex *before* editing the file. + Do this first: once `plex:` is commented out Compose no longer knows the service + exists, so `docker compose up -d` won't touch the container. It keeps running on + host networking, and you end up with both media servers indexing the library — + the one thing this section exists to avoid. +2. In `docker-compose.yml`, comment out the entire `plex:` service. +3. Uncomment the `jellyfin:` service directly below it. +4. `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) | +| | 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/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/discovery.test.ts b/dashboard/server/src/discovery.test.ts index 93e122d..bae34b4 100644 --- a/dashboard/server/src/discovery.test.ts +++ b/dashboard/server/src/discovery.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { __test } from './discovery.js'; -const { xmlTag, iniValue, SOURCES, ENV_VAR } = __test; +const { xmlTag, iniValue, yamlValue, SOURCES, ENV_VAR } = __test; // Shaped after a real Radarr config.xml. const ARR_XML = ` @@ -64,6 +64,78 @@ test('iniValue returns null for a missing key', () => { assert.equal(iniValue(TAUTULLI_INI, 'General', 'nope'), null); }); +// Shaped after a real Bazarr config/config.yaml. Bazarr is Python and writes +// YAML, not the .NET `config.xml` the *arrs use — and it stores an `apikey` +// under a dozen sections, including the *arrs it talks to. Section ordering is +// alphabetical, so `auth` genuinely sits near the top of a real file; nothing +// may depend on that. +const BAZARR_YAML = `addic7ed: + password: '' + username: '' +anticaptcha: + anti_captcha_key: '' +auth: + apikey: bazarrkey123 + password: '' + type: null +general: + port: 6767 + base_url: '' +radarr: + apikey: radarrkey_must_not_be_used + ip: 127.0.0.1 +sonarr: + apikey: sonarrkey_must_not_be_used + ip: 127.0.0.1 +subdl: + api_key: '' +`; + +test('yamlValue reads the API key from the auth section', () => { + assert.equal(yamlValue(BAZARR_YAML, 'auth', 'apikey'), 'bazarrkey123'); +}); + +test('yamlValue does not return a same-named key from another section', () => { + // The failure that matters: Bazarr stores the Radarr and Sonarr keys it was + // given under their own sections. Handing one of those back as Bazarr's own + // would authenticate against the wrong service. + assert.notEqual(yamlValue(BAZARR_YAML, 'auth', 'apikey'), 'radarrkey_must_not_be_used'); + assert.equal(yamlValue(BAZARR_YAML, 'radarr', 'apikey'), 'radarrkey_must_not_be_used'); +}); + +test('yamlValue returns null for an empty value rather than an empty string', () => { + assert.equal(yamlValue(BAZARR_YAML, 'subdl', 'api_key'), null); +}); + +test('yamlValue returns null for a missing section or key', () => { + assert.equal(yamlValue(BAZARR_YAML, 'nosuch', 'apikey'), null); + assert.equal(yamlValue(BAZARR_YAML, 'auth', 'nosuch'), null); +}); + +test('yamlValue strips quotes around a value', () => { + assert.equal(yamlValue("auth:\n apikey: 'quoted123'\n", 'auth', 'apikey'), 'quoted123'); + assert.equal(yamlValue('auth:\n apikey: "quoted456"\n', 'auth', 'apikey'), 'quoted456'); +}); + +test('yamlValue drops a trailing comment from an unquoted value', () => { + assert.equal( + yamlValue('auth:\n apikey: bazarrkey123 # generated key\n', 'auth', 'apikey'), + 'bazarrkey123', + ); +}); + +test('yamlValue keeps a # that is inside quotes', () => { + assert.equal(yamlValue('auth:\n apikey: "key#123"\n', 'auth', 'apikey'), 'key#123'); + assert.equal( + yamlValue("auth:\n apikey: 'key#123' # generated key\n", 'auth', 'apikey'), + 'key#123', + ); +}); + +test('yamlValue reads the base_url Bazarr serves under', () => { + assert.equal(yamlValue('general:\n base_url: /bazarr\n', 'general', 'base_url'), '/bazarr'); +}); + test('every source has an env override variable', () => { for (const source of SOURCES) { assert.ok(ENV_VAR[source], `${source} has no env override`); diff --git a/dashboard/server/src/discovery.ts b/dashboard/server/src/discovery.ts index 9b34017..8c06fc9 100644 --- a/dashboard/server/src/discovery.ts +++ b/dashboard/server/src/discovery.ts @@ -123,7 +123,83 @@ function iniValue(ini: string, section: string, key: string): string | null { return null; } -/** Sonarr / Radarr / Prowlarr / Bazarr all use the same `config.xml` shape. */ +/** + * Reads `key: value` from a top-level YAML section, for Bazarr's config.yaml. + * + * Same reasoning as `iniValue`: a YAML dependency for two scalars out of a file + * this stack writes itself isn't proportionate, and the section boundary has to + * be exact. Bazarr stores an `apikey` under a dozen sections — including + * `radarr:` and `sonarr:`, whose keys belong to those services — so a document- + * wide search for the first `apikey` would eventually hand back someone else's + * credential. Only two-level `section: / key: value` is understood, which is + * all the values read here are. + */ +function yamlValue(yaml: string, section: string, key: string): string | null { + let inSection = false; + + for (const rawLine of yaml.split(/\r?\n/)) { + if (!rawLine.trim() || rawLine.trimStart().startsWith('#')) continue; + + // A top-level key is unindented; anything indented belongs to the current + // one. This is what keeps `radarr:`'s apikey out of `auth:`. + if (!/^\s/.test(rawLine)) { + inSection = rawLine.split(':')[0]?.trim() === section; + continue; + } + + if (!inSection) continue; + + const separator = rawLine.indexOf(':'); + if (separator === -1) continue; + if (rawLine.slice(0, separator).trim() !== key) continue; + + // Bazarr quotes some values and not others, and writes '' for unset. Its + // config.yaml is round-tripped by ruamel, so a comment a user adds by hand + // survives every rewrite — and ` # note` trailing an unquoted key would + // otherwise become part of the credential. Inside quotes a `#` is data, + // so the comment is only stripped from the unquoted form. + const raw = rawLine.slice(separator + 1).trim(); + const quoted = /^(['"])((?:(?!\1).)*)\1/.exec(raw); + const value = quoted ? quoted[2] : raw.replace(/\s+#.*$/, '').trim(); + return value || null; + } + + return null; +} + +/** + * Bazarr, which despite sitting alongside the *arrs shares none of their + * config format. + * + * It is Python, not .NET: there is no `config.xml` anywhere in its tree, and + * treating it as an *arr left this integration permanently `waiting` while + * telling the user to start Bazarr — advice that could never help, because + * Bazarr had already written the config we weren't reading. The key lives at + * `config/config.yaml` *inside* the config directory, so it is reachable + * through the existing `${USERDIR}/bazarr/config` mount with no compose change. + */ +async function discoverBazarr(): Promise> { + const yaml = await readIfPresent(join(DISCOVER_ROOT, 'bazarr', 'config', 'config.yaml')); + if (!yaml) { + return { apiKey: null, state: 'waiting', origin: 'none', hint: WAITING_HINT.bazarr, urlBase: '' }; + } + + const apiKey = yamlValue(yaml, 'auth', 'apikey'); + const urlBase = yamlValue(yaml, 'general', 'base_url') ?? ''; + + if (!apiKey) { + return { + apiKey: null, + state: 'waiting', + origin: 'none', + hint: 'Found Bazarr’s config.yaml but no auth.apikey in it yet.', + urlBase, + }; + } + return { apiKey, state: 'live', origin: 'discovered', hint: null, urlBase }; +} + +/** Sonarr / Radarr / Prowlarr all use the same `config.xml` shape. */ async function discoverArr(source: SourceId): Promise> { const xml = await readIfPresent(join(DISCOVER_ROOT, source, 'config.xml')); if (!xml) { @@ -221,7 +297,9 @@ async function discoverOne(source: SourceId): Promise { ? await discoverTautulli() : source === 'seerr' ? await discoverSeerr() - : await discoverArr(source); + : source === 'bazarr' + ? await discoverBazarr() + : await discoverArr(source); return { source, ...found }; } @@ -247,4 +325,4 @@ export async function credentialFor(source: SourceId): Promise { return (await getCredentials())[source]; } -export const __test = { xmlTag, iniValue, SOURCES, ENV_VAR }; +export const __test = { xmlTag, iniValue, yamlValue, SOURCES, ENV_VAR }; diff --git a/dashboard/server/src/http.test.ts b/dashboard/server/src/http.test.ts new file mode 100644 index 0000000..f7d2a81 --- /dev/null +++ b/dashboard/server/src/http.test.ts @@ -0,0 +1,78 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { describeError, safely, upstreamHint } from './http.js'; + +const SONARR = { name: 'Sonarr', urlVar: 'SONARR_URL' }; + +test('upstreamHint sends an unresolvable host to the network, not the API key', () => { + // The failure that motivated this: a service on a network the dashboard + // doesn't share resolves to nothing, and a bare reason with no next step + // sends people to check credentials that were never the problem. + const hint = upstreamHint(SONARR)('host not found'); + assert.match(hint, /network/i); + assert.match(hint, /SONARR_URL/); + assert.doesNotMatch(hint, /API key/i); +}); + +test('upstreamHint sends a rejected credential to the API key, not the network', () => { + const hint = upstreamHint(SONARR)('authentication rejected (401)'); + assert.match(hint, /API key/i); + assert.doesNotMatch(hint, /network/i); +}); + +test('upstreamHint distinguishes a refused connection from an unresolved one', () => { + const refused = upstreamHint(SONARR)('connection refused'); + const missing = upstreamHint(SONARR)('host not found'); + assert.notEqual(refused, missing); + assert.match(refused, /starting|listening/i); +}); + +test('upstreamHint names the service in every branch', () => { + for (const reason of [ + 'host not found', + 'connection refused', + 'upstream timed out', + 'authentication rejected (403)', + 'something nobody predicted', + ]) { + assert.match(upstreamHint(SONARR)(reason), /Sonarr/, `no service name for: ${reason}`); + } +}); + +test('describeError maps a DNS failure to a host-not-found reason', () => { + // EAI_AGAIN is what a container name on an unshared network actually returns. + const dns = Object.assign(new Error('fetch failed'), { cause: { code: 'EAI_AGAIN' } }); + assert.equal(describeError(dns), 'host not found'); +}); + +test('safely accepts a reason-derived hint, not just a fixed string', async () => { + const result = await safely(async () => { + throw Object.assign(new Error('fetch failed'), { cause: { code: 'ENOTFOUND' } }); + }, upstreamHint(SONARR)); + + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.reason, 'host not found'); + assert.match(result.hint ?? '', /SONARR_URL/); +}); + +test('safely still accepts a plain string hint', async () => { + const result = await safely(async () => { + throw new Error('boom'); + }, 'do the thing'); + + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.hint, 'do the thing'); +}); + +test('safely attaches no hint when none is given', async () => { + const result = await safely(async () => { + throw new Error('boom'); + }); + + assert.equal(result.available, false); + if (result.available) return; + assert.equal(result.hint, undefined); +}); diff --git a/dashboard/server/src/http.ts b/dashboard/server/src/http.ts index 8188a37..4adedf7 100644 --- a/dashboard/server/src/http.ts +++ b/dashboard/server/src/http.ts @@ -53,17 +53,57 @@ export async function getJson(url: string, headers: Record = return (await response.json()) as T; } +/** An upstream this dashboard talks to over HTTP, for building failure hints. */ +export interface Upstream { + /** Display name as the user knows it, e.g. "Sonarr". */ + name: string; + /** The env var overriding its base URL, e.g. `SONARR_URL`. */ + urlVar: string; +} + +/** + * Builds the next step that actually matches a failure. + * + * Generalises what `hintFor` in `transmission.ts` does for one service: a + * rejected credential and an unreachable host need completely different fixes, + * and a generic hint sends people looking in the wrong place. Without this, + * every source except Transmission reported a bare reason and no next step — so + * a service on a network the dashboard doesn't share read "upstream timed out", + * and the natural guess is to go re-check an API key that was never at fault. + */ +export function upstreamHint({ name, urlVar }: Upstream): (reason: string) => string { + return (reason) => { + if (reason.includes('authentication')) { + return `${name} rejected the dashboard's API key. Confirm the key in ${name}'s own settings, or set it in .env to override what was discovered.`; + } + if (reason.includes('host not found')) { + return `Nothing resolved ${name}'s hostname. It has to be defined in docker-compose.yml and share a network with the dashboard — or set ${urlVar} in .env to reach it another way.`; + } + if (reason.includes('refused')) { + return `${name} resolved but refused the connection, so it is probably still starting, or not listening on the port ${urlVar} points at.`; + } + if (reason.includes('timed out')) { + return `${name} did not answer in time — either still starting, or not reachable from the dashboard's networks. ${urlVar} overrides where to look.`; + } + return `Check the ${name} container logs.`; + }; +} + /** * Wraps a source function so it always resolves. This is the single place the * "one dead upstream must never blank the page" rule is enforced. + * + * The hint may be a function of the reason rather than a fixed string, because + * the useful next step usually depends on how the call failed. */ export async function safely( load: () => Promise, - hint?: string, + hint?: string | ((reason: string) => string), ): Promise> { try { return { ...(await load()), available: true }; } catch (error) { - return unavailable(describeError(error), hint); + const reason = describeError(error); + return unavailable(reason, typeof hint === 'function' ? hint(reason) : hint); } } diff --git a/dashboard/server/src/services.ts b/dashboard/server/src/services.ts index 2701880..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,18 @@ export const SERVICES: readonly ServiceDef[] = [ hue: 'amber', port: 32400, blurb: 'Central media server', + optional: true, + }, + { + id: 'jellyfin', + name: 'Jellyfin', + mono: 'JF', + container: 'jellyfin', + group: 'media', + hue: 'violet', + port: 8096, + blurb: 'Alternative media server', + optional: true, }, { id: 'seerr', diff --git a/dashboard/server/src/sources/activity.ts b/dashboard/server/src/sources/activity.ts index a1e7c7b..1a633ab 100644 --- a/dashboard/server/src/sources/activity.ts +++ b/dashboard/server/src/sources/activity.ts @@ -1,7 +1,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { credentialFor } from '../discovery.js'; -import { safely, type Result } from '../http.js'; +import { safely, upstreamHint, type Result } from '../http.js'; import { history } from './arr.js'; /** @@ -84,7 +84,9 @@ async function load(): Promise<{ items: ActivityItem[] }> { } export const getActivity = memoize>( - () => safely(load), + // Sonarr and Radarr both feed this and the reason can't say which failed, + // so the hint names the pair rather than guessing one of them. + () => safely(load, upstreamHint({ name: 'Sonarr/Radarr', urlVar: 'SONARR_URL / RADARR_URL' })), config.ttl.activity, ); diff --git a/dashboard/server/src/sources/docker.test.ts b/dashboard/server/src/sources/docker.test.ts index a0a177e..ebf3e66 100644 --- a/dashboard/server/src/sources/docker.test.ts +++ b/dashboard/server/src/sources/docker.test.ts @@ -71,6 +71,13 @@ test('a proxy outage keeps the last good report instead of blanking it', async ( 'up', 'a transient outage must not flip services to absent', ); + assert.equal( + degraded.statesKnown, + true, + 'states are stale during a blip, but they were still observed — the UI keys ' + + 'link suppression off this, and treating a hiccup as "unknown" would flip ' + + 'every optional service back to linkable', + ); }); test('a failure before any successful poll reports nothing rather than lying', async (t) => { @@ -90,6 +97,12 @@ test('a failure before any successful poll reports nothing rather than lying', a assert.equal(report.reachable, false); assert.equal(report.total, 0); assert.ok(report.services.every((s) => s.state === 'absent')); + assert.equal( + report.statesKnown, + false, + 'nothing was ever observed, so those absents are placeholders and the UI ' + + 'must not treat them as real absences', + ); }); test('catalog: ids and container names are unique', () => { diff --git a/dashboard/server/src/sources/docker.ts b/dashboard/server/src/sources/docker.ts index 4018f7f..9077a7c 100644 --- a/dashboard/server/src/sources/docker.ts +++ b/dashboard/server/src/sources/docker.ts @@ -37,6 +37,18 @@ export interface HealthReport { attention: string[]; /** False when the socket proxy is unreachable; the UI says so explicitly. */ reachable: boolean; + /** + * Whether the per-service `state` values were actually observed, as opposed + * to placeholders. This is NOT the inverse of `reachable`, and the difference + * matters: an outage *after* a successful poll still carries real states, just + * stale ones, so this stays true while `reachable` goes false. It is only + * false on the cold path, where nothing has ever been observed and every + * service is reported `absent` because we have nothing better to say. + * + * The UI keys "is this service really missing" off this rather than + * `reachable` — see `launchUrl` in the web app. + */ + statesKnown: boolean; } function classify(container: DockerContainer): ServiceState { @@ -89,13 +101,16 @@ async function buildReport(): Promise { // real data flagged as stale rather than an empty stack. if (lastGoodReport) return { ...lastGoodReport, reachable: false }; - // Nothing known yet — this is the first call and it failed. + // Nothing known yet — this is the first call and it failed. Every service + // reads `absent` here as a placeholder, not an observation, which is what + // `statesKnown: false` tells the UI. return { services: SERVICES.map((service) => ({ ...service, state: 'absent', status: null })), up: 0, total: 0, attention: [], reachable: false, + statesKnown: false, }; } @@ -116,6 +131,7 @@ async function buildReport(): Promise { total: present.length, attention: present.filter((s) => s.state === 'attn' || s.state === 'down').map((s) => s.name), reachable: true, + statesKnown: true, }; lastGoodReport = report; diff --git a/dashboard/server/src/sources/prometheus.ts b/dashboard/server/src/sources/prometheus.ts index 36acd04..e5eb744 100644 --- a/dashboard/server/src/sources/prometheus.ts +++ b/dashboard/server/src/sources/prometheus.ts @@ -1,6 +1,6 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; -import { getJson, safely, type Result } from '../http.js'; +import { getJson, safely, upstreamHint, type Result } from '../http.js'; /** * Resource gauges, from node-exporter via Prometheus. @@ -112,7 +112,7 @@ async function load(): Promise<{ gauges: Gauge[] }> { } export const getMetrics = memoize>( - () => safely(load, 'Prometheus scrapes node-exporter; check both are running.'), + () => safely(load, upstreamHint({ name: 'Prometheus', urlVar: 'PROMETHEUS_URL' })), config.ttl.metrics, ); diff --git a/dashboard/server/src/sources/seerr.ts b/dashboard/server/src/sources/seerr.ts index 05aba52..27dafc1 100644 --- a/dashboard/server/src/sources/seerr.ts +++ b/dashboard/server/src/sources/seerr.ts @@ -1,7 +1,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { credentialFor } from '../discovery.js'; -import { getJson, safely, unavailable, type Result } from '../http.js'; +import { getJson, safely, unavailable, upstreamHint, type Result } from '../http.js'; import { tmdbPoster } from './posters.js'; /** Content requests, from Seerr. */ @@ -187,7 +187,7 @@ export const getRequests = memoize>(async () => { if (credential.state !== 'live') { return unavailable('Waiting for Seerr', credential.hint ?? undefined); } - return safely(load); + return safely(load, upstreamHint({ name: 'Seerr', urlVar: 'SEERR_URL' })); }, config.ttl.requests); export const __test = { relative, statusOf, pendingCount, describe }; diff --git a/dashboard/server/src/sources/tautulli.ts b/dashboard/server/src/sources/tautulli.ts index 8500183..ffc8f9e 100644 --- a/dashboard/server/src/sources/tautulli.ts +++ b/dashboard/server/src/sources/tautulli.ts @@ -1,7 +1,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { credentialFor } from '../discovery.js'; -import { getJson, safely, unavailable, type Result } from '../http.js'; +import { getJson, safely, unavailable, upstreamHint, type Result } from '../http.js'; import { plexPoster } from './posters.js'; /** Now Playing, from Tautulli's `get_activity` command. */ @@ -169,7 +169,7 @@ export const getStreams = memoize>(async () => { credential.hint ?? undefined, ); } - return safely(load); + return safely(load, upstreamHint({ name: 'Tautulli', urlVar: 'TAUTULLI_URL' })); }, config.ttl.streams); export const __test = { duration, mode, monogram, toStream }; diff --git a/dashboard/server/src/sources/upcoming.ts b/dashboard/server/src/sources/upcoming.ts index 7f7fc49..385c599 100644 --- a/dashboard/server/src/sources/upcoming.ts +++ b/dashboard/server/src/sources/upcoming.ts @@ -1,7 +1,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { credentialFor } from '../discovery.js'; -import { safely, unavailable, type Result } from '../http.js'; +import { safely, unavailable, upstreamHint, type Result } from '../http.js'; import { calendar, type CalendarEpisode } from './arr.js'; /** @@ -43,5 +43,5 @@ export const getUpcoming = memoize>(async () = if (credential.state !== 'live') { return unavailable('Waiting for Sonarr', credential.hint ?? undefined); } - return safely(load); + return safely(load, upstreamHint({ name: 'Sonarr', urlVar: 'SONARR_URL' })); }, config.ttl.calendar); 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..8202b47 100644 --- a/dashboard/web/src/app/App.tsx +++ b/dashboard/web/src/app/App.tsx @@ -44,6 +44,14 @@ 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 + // never managed to read container state — see `launchUrl`. Keyed off + // `statesKnown`, NOT `reachable`: a blip after a successful poll keeps the + // real states and only marks them stale, so `reachable` alone would treat a + // known-absent service as unknown for the duration of every hiccup. The + // catalog fallback carries no state at all, so it's unaffected either way. + const stateKnown = health.data?.statesKnown === true; + const alerts = useMemo( () => deriveAlerts(health.data, integrations.data?.integrations ?? []), [health.data, integrations.data], @@ -75,7 +83,13 @@ export function App() { fontFamily: 'var(--font-body)', }} > - +
setView('setup')} /> @@ -167,7 +182,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 6653e67..ff3f770 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 { isMissing, launchUrl, type ServiceGroup, type ServiceStatus } from '../types'; import type { Alert } from '../alerts'; import type { Theme } from '../hooks/useTheme'; @@ -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({
- - + +
diff --git a/dashboard/web/src/search.test.ts b/dashboard/web/src/search.test.ts new file mode 100644 index 0000000..3c3cc32 --- /dev/null +++ b/dashboard/web/src/search.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { match, score } from './search'; +import type { ServiceGroup, ServiceStatus } from './types'; + +(globalThis as unknown as { window: unknown }).window = { + location: { protocol: 'http:', hostname: 'nas.local' }, +}; + +const GROUPS: { id: ServiceGroup }[] = [{ id: 'media' }, { id: 'downloads' }]; + +function service(over: Partial & { id: string }): ServiceStatus { + return { + name: over.id, + mono: over.id.slice(0, 2).toUpperCase(), + container: over.id, + group: 'media', + hue: 'violet', + port: 8096, + blurb: '', + state: 'up', + status: null, + ...over, + }; +} + +const PLEX = service({ id: 'plex', name: 'Plex', port: 32400 }); +const JELLYFIN = service({ + id: 'jellyfin', + name: 'Jellyfin', + port: 8096, + state: 'absent', + optional: true, +}); +const BAZARR = service({ id: 'bazarr', name: 'Bazarr', port: 6767, state: 'absent' }); +const WATCHTOWER = service({ id: 'watchtower', name: 'Watchtower', port: null }); + +test('an uninstalled optional service is not an openable result', () => { + // Otherwise it is keyboard-navigable and Enter opens a tab that refuses. + const { openable, unopenable } = match([PLEX, JELLYFIN], GROUPS, 'jelly'); + assert.deepEqual( + openable.map((s) => s.id), + [], + ); + assert.deepEqual( + unopenable.map((s) => s.id), + ['jellyfin'], + ); +}); + +test('an uninstalled optional service is still named, rather than vanishing from search', () => { + const { unopenable } = match([PLEX, JELLYFIN], GROUPS, 'jellyfin'); + assert.equal(unopenable.length, 1); +}); + +test('openable results carry a resolved href', () => { + const { openable } = match([PLEX], GROUPS, 'plex'); + assert.equal(openable.length, 1); + assert.equal(openable[0]!.href, 'http://nas.local:32400'); + assert.equal(openable[0]!.port, 32400); +}); + +test('a UI-less service is named but not offered as a result', () => { + const { openable, unopenable } = match([WATCHTOWER], GROUPS, 'watch'); + assert.equal(openable.length, 0); + assert.deepEqual( + unopenable.map((s) => s.id), + ['watchtower'], + ); +}); + +test('absent optional services become openable again when container state is unknown', () => { + // Socket proxy down on a cold start — search must not go dead too. + const { openable } = match([PLEX, JELLYFIN], GROUPS, 'jelly', false); + assert.deepEqual( + openable.map((s) => s.id), + ['jellyfin'], + ); +}); + +test('services outside a visible group are not searchable', () => { + const proxy = service({ id: 'docker-socket-proxy', name: 'Socket Proxy', group: 'system' }); + const { openable, unopenable } = match([proxy], GROUPS, 'socket'); + assert.equal(openable.length, 0); + assert.equal(unopenable.length, 0); +}); + +test('an empty query matches nothing', () => { + const { openable, unopenable } = match([PLEX, JELLYFIN], GROUPS, ' '); + assert.equal(openable.length, 0); + assert.equal(unopenable.length, 0); +}); + +test('a name prefix outranks a mere substring', () => { + // Typing "so" should reach Sonarr before Flaresolverr. + assert.ok(score(service({ id: 'sonarr', name: 'Sonarr' }), 'so')! < + score(service({ id: 'flaresolverr', name: 'Flaresolverr' }), 'so')!); +}); + +test('an absent non-optional service stays openable', () => { + // A renamed container should not silently cost the user a working link. + const { openable } = match([BAZARR], GROUPS, 'bazarr'); + assert.deepEqual( + openable.map((s) => s.id), + ['bazarr'], + ); +}); diff --git a/dashboard/web/src/search.ts b/dashboard/web/src/search.ts new file mode 100644 index 0000000..0d66f71 --- /dev/null +++ b/dashboard/web/src/search.ts @@ -0,0 +1,85 @@ +import { launchUrl, type ServiceGroup, type ServiceStatus } from './types'; + +/** + * Ranking and filtering for the header's service search. + * + * Kept apart from `CommandSearch.tsx` so it stays free of React and of Vite-only + * syntax — the component tree reaches `import.meta.glob` through `ServiceIcon`, + * which plain `node --test` can't evaluate. Pure module, directly testable. + */ + +export const MAX_RESULTS = 7; + +/** A service is openable when it publishes a web UI and is actually installed. */ +export 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 nothing to open, named but not offered as results. */ + unopenable: ServiceStatus[]; +} + +/** + * Ranks a service against a query. Lower is better; `null` means no match. + * + * Name matches beat blurb matches, and a name that starts with the query beats + * one that merely contains it — typing "so" should reach Sonarr before + * Flaresolverr. + */ +export function score(service: ServiceStatus, query: string): number | null { + const name = service.name.toLowerCase(); + if (name.startsWith(query)) return 0; + if (name.includes(query)) return 1; + if (service.id.includes(query)) return 2; + if (service.blurb.toLowerCase().includes(query)) return 3; + return null; +} + +/** + * Filters the catalog for the search menu. + * + * Only services in a visible group are searchable, matching what the sidebar + * and Launcher show — `system` services like the socket proxy have no UI and + * aren't things a user navigates to. + * + * Services with nothing to open are split off rather than dropped: they'd be + * rows that do nothing on Enter, or worse, open a tab that connection-refuses. + * `stateKnown` is threaded through to `launchUrl` so an unreachable socket + * proxy doesn't empty the menu — see that function for why. + */ +export function match( + services: ServiceStatus[], + groups: readonly { id: ServiceGroup }[], + rawQuery: string, + stateKnown = true, +): Matches { + const query = rawQuery.trim().toLowerCase(); + if (query === '') return { openable: [], unopenable: [] }; + + const visible = new Set(groups.map((group) => group.id)); + const ranked = services + .filter((service) => visible.has(service.group)) + .flatMap((service) => { + const rank = score(service, query); + return rank === null ? [] : [{ service, rank }]; + }) + .sort((a, b) => a.rank - b.rank || a.service.name.localeCompare(b.service.name)); + + const withHref = ranked.map(({ service }) => ({ + service, + href: launchUrl(service, stateKnown), + })); + + return { + openable: withHref + .flatMap(({ service, href }) => + href === null || service.port === null ? [] : [{ ...service, port: service.port, href }], + ) + .slice(0, MAX_RESULTS), + unopenable: withHref.flatMap(({ service, href }) => (href === null ? [service] : [])), + }; +} diff --git a/dashboard/web/src/types.test.ts b/dashboard/web/src/types.test.ts new file mode 100644 index 0000000..eb9bb03 --- /dev/null +++ b/dashboard/web/src/types.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { isMissing, launchUrl, type ServiceStatus } from './types'; + +// `serviceUrl` builds links against whatever host the dashboard was loaded +// from, so these tests need a window to read. Set before importing anything +// that calls it at module scope — nothing does today, but the stub is cheap. +(globalThis as unknown as { window: unknown }).window = { + location: { protocol: 'http:', hostname: 'nas.local' }, +}; + +function service( + over: Partial = {}, +): Pick { + return { port: 8096, state: 'up', ...over }; +} + +test('a running service links to its port on the dashboard host', () => { + assert.equal(launchUrl(service()), 'http://nas.local:8096'); +}); + +test('a service with no web UI has nothing to open', () => { + assert.equal(launchUrl(service({ port: null })), null); +}); + +test('a stopped service still links — the container exists and may be started', () => { + assert.equal(launchUrl(service({ state: 'down' })), 'http://nas.local:8096'); +}); + +test('an uninstalled optional service does not link', () => { + // Jellyfin ships commented out of docker-compose.yml but stays in the + // catalog, so a Plex user would otherwise get a tile linking at :8096. + assert.equal(launchUrl(service({ state: 'absent', optional: true })), null); +}); + +test('an absent NON-optional service keeps its link', () => { + // Compose always defines Radarr, so `absent` here means something unexpected + // — most often a renamed container. Dropping a link that still works would + // hide the mismatch instead of surfacing it. + assert.equal(launchUrl(service({ state: 'absent', port: 7878 })), 'http://nas.local:7878'); +}); + +test('absent optional services still link when container state could not be read', () => { + // The regression this guards: on a cold start with the socket proxy down, + // `buildReport` reports every service absent — including both halves of the + // Plex/Jellyfin swap, the two tiles a user most wants at that moment. + assert.equal( + launchUrl(service({ state: 'absent', optional: true }), false), + 'http://nas.local:8096', + ); +}); + +test('a UI-less service stays unopenable even when state is unknown', () => { + // Port is static catalog data — unreachable Docker tells us nothing new. + assert.equal(launchUrl(service({ port: null, state: 'absent' }), false), null); +}); + +test('isMissing separates a real absence from an unreadable one', () => { + // The header's Request shortcut hides itself on the first and not the second. + assert.equal(isMissing({ state: 'absent' }), true); + assert.equal(isMissing({ state: 'absent' }, false), false); + assert.equal(isMissing({ state: 'down' }), false); + assert.equal(isMissing({ state: 'up' }), false); +}); + +test('isMissing is true for a non-optional service, even though launchUrl links it', () => { + // The regression this guards: narrowing launchUrl to `optional` services left + // the Request button rendering a live link to an absent Seerr, because it had + // delegated its own absence check to launchUrl. The two questions are + // different and both callers need the right one. + const seerr = service({ state: 'absent', port: 5055 }); + assert.equal(launchUrl(seerr), 'http://nas.local:5055'); + assert.equal(isMissing(seerr), true); +}); diff --git a/dashboard/web/src/types.ts b/dashboard/web/src/types.ts index f1bc412..c3d26fe 100644 --- a/dashboard/web/src/types.ts +++ b/dashboard/web/src/types.ts @@ -13,6 +13,8 @@ export interface Service { hue: Hue; port: number | null; blurb: string; + /** Compose may legitimately not define this service — see `launchUrl`. */ + optional?: boolean; } export interface ServiceStatus extends Service { @@ -26,6 +28,12 @@ export interface HealthReport { total: number; attention: string[]; reachable: boolean; + /** + * Whether the per-service states were observed rather than placeholders. Not + * the inverse of `reachable` — a blip after a good poll keeps real (stale) + * states, so this stays true. See the server's `HealthReport`. + */ + statesKnown: boolean; } export const HUE_VAR: Record = { @@ -62,6 +70,58 @@ 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); + * - an `optional` service isn't installed. Only services flagged `optional` in + * the catalog — the two halves of the Plex/Jellyfin swap — are suppressed + * this way, because for them `absent` is a choice the user made and the port + * leads nowhere. + * + * Everything else keeps its link even when absent. A catalog entry can go + * `absent` merely because the container was renamed, and silently removing a + * working link would hide that rather than surface it. + * + * `down` deliberately still yields a URL: the container exists and the user may + * be about to start it. + * + * `stateKnown` is the report's `reachable` flag, and it matters more than it + * looks. When the socket proxy can't be reached on a cold start the server has + * no last-good report to fall back on, so it reports *every* service as + * `absent` (see `buildReport` in `sources/docker.ts`). Suppressing on `absent` + * alone would strip every link in the UI at exactly the moment a user most + * needs the launcher — a dead upstream blanking the page, which this app + * doesn't do. So `absent` only means "not installed" when we actually reached + * Docker; otherwise it means "don't know", and a link is better than no link. + */ +export function launchUrl( + service: Pick, + stateKnown = true, +): string | null { + if (service.port === null) return null; + if (service.optional && isMissing(service, stateKnown)) return null; + return serviceUrl(service.port); +} + +/** + * Whether a service is genuinely not on this host, as opposed to merely + * reported `absent` because container state couldn't be read. + * + * The single predicate for that question. `launchUrl` applies it only to + * `optional` services; callers that want it for a specific non-optional service + * — the header's Request shortcut, which has nothing to shortcut to when Seerr + * isn't installed — call it directly rather than reimplementing the check. + */ +export function isMissing( + service: Pick, + stateKnown = true, +): boolean { + return service.state === 'absent' && stateKnown; +} + // ---- 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..1a4d15b 100644 --- a/dashboard/web/src/views/Launcher.tsx +++ b/dashboard/web/src/views/Launcher.tsx @@ -2,15 +2,17 @@ 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[]; groups: readonly { id: ServiceGroup; label: string }[]; + /** Whether container state is trustworthy — see `launchUrl`. */ + stateKnown: boolean; } /** The grouped grid of service tiles — the "open everything from one place" view. */ -export function Launcher({ services, groups }: Props) { +export function Launcher({ services, groups, stateKnown }: Props) { return (
{groups.map((group) => { @@ -30,7 +32,7 @@ export function Launcher({ services, groups }: Props) { }} > {items.map((service) => ( - + ))}
@@ -40,11 +42,11 @@ export function Launcher({ services, groups }: Props) { ); } -function ServiceTile({ service }: { service: ServiceStatus }) { +function ServiceTile({ service, stateKnown }: { service: ServiceStatus; stateKnown: boolean }) { 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, stateKnown); const body = ( <> diff --git a/docker-compose.yml b/docker-compose.yml index 5849269..5f2b011 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,17 @@ name: autoplexx services: + # LinuxServer images are pulled from lscr.io, their own registry, rather than + # the bare `linuxserver/*` Docker Hub names. Same images, but anonymous Docker + # Hub pulls are rate-limited (and on some hosts refused outright), and a first + # `docker compose up` on this stack is a couple of dozen pulls at once — + # exactly when hitting a limit is most likely and most confusing. Only the + # LinuxServer images can move; the rest have no equivalent mirror. + # ============ MEDIA SERVER ============ plex: container_name: plex - image: linuxserver/plex + image: lscr.io/linuxserver/plex network_mode: "host" environment: - PUID=${PUID} @@ -16,14 +23,41 @@ 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: run `docker compose rm -sf plex` FIRST, then comment out + # the entire `plex:` service above, uncomment the `jellyfin:` service below, + # and run `docker compose up -d`. Removing Plex first matters: a commented-out + # service is invisible to compose, so `up -d` would leave the old container + # running and you'd have both media servers on the same library. + # 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: lscr.io/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 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: @@ -226,7 +260,7 @@ services: radarr: container_name: radarr - image: linuxserver/radarr + image: lscr.io/linuxserver/radarr networks: - media_network - download_network @@ -243,7 +277,7 @@ services: sonarr: container_name: sonarr - image: linuxserver/sonarr + image: lscr.io/linuxserver/sonarr networks: - media_network - download_network @@ -260,7 +294,7 @@ services: prowlarr: container_name: prowlarr - image: linuxserver/prowlarr + image: lscr.io/linuxserver/prowlarr networks: - media_network ports: @@ -275,7 +309,7 @@ services: bazarr: container_name: bazarr - image: linuxserver/bazarr + image: lscr.io/linuxserver/bazarr networks: - media_network ports: 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..806d0e6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-jellyfin-variant.md @@ -0,0 +1,311 @@ +# 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 against a throwaway copy of `.env.example` passed with `--env-file`, filling `DB_PASSWORD`, `JWT_SECRET`, `COOKIE_SECRET`, `OPENVPN_PROVIDER`, `OPENVPN_CONFIG`, `OPENVPN_USERNAME`, `OPENVPN_PASSWORD`, as `.github/workflows/compose-validate.yml` does. **Never write to the repo's own `.env`** — it holds the contributor's real credentials. +- **A dashboard `container` field must equal `container_name` in compose exactly**, or health lookups silently report `absent`. +- **No new env var** — `.env.example` stays as it is. Vars there are consumed by compose, with the documented exception of `RADARR_API_KEY` / `SONARR_API_KEY`, which Decluttarr's user fills in after first boot; that exception is not a licence to add more. A var used only by a commented-out service would be a third kind, 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: run `docker compose rm -sf plex` FIRST, then comment out + # the entire `plex:` service above, uncomment the `jellyfin:` service below, + # and run `docker compose up -d`. Removing Plex first matters: a commented-out + # service is invisible to compose, so `up -d` would leave the old container + # running and you'd have both media servers on the same library. + # 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 both the default (Plex) file and the enabled Jellyfin block validate** + +One block, because the throwaway env file and the scratch copy have to outlive each +other. Everything lands in a `mktemp -d` directory removed by a trap, so the repo's +`docker-compose.yml` and — importantly — the contributor's real `.env` are never touched: + +```bash +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT + +# A throwaway env file, never the repo's own .env. +cp .env.example "$SCRATCH/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" +} >> "$SCRATCH/env" + +docker compose --env-file "$SCRATCH/env" config --quiet && echo "DEFAULT OK" + +# Prove the block parses when a user uncomments it, on a scratch copy. +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 "$SCRATCH/env" config --quiet \ + && echo "JELLYFIN BLOCK OK" +``` +Expected: prints `DEFAULT OK` (the commented Jellyfin block is invisible to the parser; Plex is unchanged) then `JELLYFIN BLOCK OK`. If the second fails, the indentation in the commented block is wrong — fix Step 1 and re-run. +(Everything lives outside the repo, so there is nothing to clean up for git.) + +- [ ] **Step 3: 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: + +```dotenv +# ============ Plex ============ +# Obtain immediately before `docker compose up -d`. Claim tokens expire in +# roughly 4 minutes. See https://www.plex.tv/claim +PLEX_CLAIM= +``` + +to: + +```dotenv +# ============ 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. ✓ 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..eb88545 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-jellyfin-variant-design.md @@ -0,0 +1,109 @@ +# 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 removes the running Plex container +(`docker compose rm -sf plex`), then comments out `plex:` and uncomments `jellyfin:`. +The removal has to come first: compose only manages services it can still see, so a +commented-out `plex:` leaves its container running rather than tearing it down. 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, including the `docker compose rm -sf plex` + that has to precede it: 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 +brief comment near the Plex vars pointing at the Jellyfin option. Deliberately introduce +**no new env var**. `.env.example` today holds vars compose consumes, plus one documented +exception — `RADARR_API_KEY` / `SONARR_API_KEY`, which the user fills in after first boot +because the *arr UIs don't exist until then. A var consumed only by a commented-out +service would be a third category with no such justification, so Jellyfin gets none. + +### 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.