Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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) ============
Expand Down
59 changes: 57 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` — 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.
Expand All @@ -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.

Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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`:
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

</div>

Expand All @@ -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

Expand Down Expand Up @@ -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://<host>: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 |
Comment on lines +167 to +172

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproducing this. The rows are | | Kometa / Plex Meta Manager | — a single leading pipe followed by an empty first cell, which is a valid 2-column row. No || appears in the file (grep -n "^||" README.md is empty).


**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 |
Expand Down
2 changes: 2 additions & 0 deletions dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 73 additions & 1 deletion dashboard/server/src/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<Config>
Expand Down Expand Up @@ -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`);
Expand Down
Loading
Loading