Skip to content

rlmsinclair/plex-server

Repository files navigation

plex-server

Self-hosted media-automation stack on macOS + Docker Compose, reachable from anywhere under robertsinclair.me behind one login (Cloudflare Tunnel + Authelia) with no inbound ports opened on the router. Everything lands on a single external SSD at /Volumes/SSD/Plex and is served by Plex (movies/TV/music) and Audiobookshelf (audiobooks).

This repo is the portable definition + documentation: docker-compose.yml, reverse-proxy and launchd config, helper scripts, and this README. It holds no secrets and no live app state — those live in a git-ignored .env and in the per-app ./<app>/ config dirs (also git-ignored).

No VPN. An earlier version routed qBittorrent through gluetun/NordVPN with FlareSolverr for Cloudflare-protected public indexers. Both were removed — this stack uses private trackers only (passkey/session-authenticated), so torrent traffic goes out the real connection. Do not add public trackers to Prowlarr/qBittorrent while there is no VPN.

Pipelines (what this actually does)

MOVIES & TV   Plex Watchlist ─30s→ Overseerr ─auto→ Radarr / Sonarr ─→ Prowlarr ─→ qBittorrent ─→ /Volumes/SSD/Plex/{Movies,Tv Shows} ─→ Plex
MUSIC         Spotify liked songs (CSV) ─→ scripts/slskd_bulk_download.py ─→ slskd (Soulseek, FLAC-only) ─→ organize-soulseek-downloads.sh ─→ /Volumes/SSD/Plex/Music ─→ Plex
AUDIOBOOKS    LazyLibrarian ─→ MyAnonaMouse (via Prowlarr Torznab) ─→ qBittorrent (staging) ─→ organize/copy ─→ /Volumes/SSD/Plex/Audiobooks ─→ Audiobookshelf / Plappa
EBOOKS        LazyLibrarian ─→ MyAnonaMouse (same provider, ebook cats) ─→ qBittorrent ─→ /Volumes/SSD/Plex/Books ─→ Kavita (web reader + OPDS for iPhone)
SPOTIFY app   spotify.robertsinclair.me: search Spotify, record a track into the Plex music library (see spotify-web/)
  • Movies & TV is fully hands-off: add to your Plex Watchlist → downloaded and imported.
  • Music is driven by your Spotify liked songs, downloaded lossless from Soulseek (see Music pipeline). Lidarr is retired from this stack (see note).
  • Audiobooks are searched in LazyLibrarian, grabbed from MyAnonaMouse via qBittorrent, and served by Audiobookshelf (web + the Plappa iOS app).

Prerequisites

  • macOS with Docker Desktop (give the VM ≥ 4 GB RAM — 1 GB caused an OOM crash-loop).
  • An external/second disk mounted at /Volumes/SSD/Plex (rename in docker-compose.yml if yours differs), with these subfolders (created below): Movies, Tv Shows, Music, Photos, Soulseek, Torrents, Audiobooks.
  • Plex Media Server running as the native macOS app (not a container), libraries pointed at /Volumes/SSD/Plex/{Movies,Tv Shows,Music}.
  • A Cloudflare account with the robertsinclair.me zone, and cloudflared + wrangler CLIs.
  • Accounts you want to use: Soulseek (music), MyAnonaMouse (audiobooks), and any private movie/TV trackers (PassThePopcorn, BroadcasTheNet, …). Bring your own keys — entered in each app's own UI, never committed.
  • A Spotify developer app (for the liked-songs export and the spotify-web recorder).

Install from scratch

  1. Clone this repo to the host that will run the stack.
  2. .envcp .env.example .env and fill in real values (Soulseek login, slskd web password, Cloudflare tunnel token, Plex token/section ids, Spotify creds). App API keys get filled in later, after each app's first-run.
  3. Library folders:
    mkdir -p "/Volumes/SSD/Plex/"{Movies,"Tv Shows",Music,Photos,Soulseek,Torrents,Audiobooks}
  4. Reverse-proxy + auth config (git-ignored, hold secrets):
    cp authelia/configuration.yml.example authelia/configuration.yml
    cp authelia/users_database.yml.example authelia/users_database.yml
    Generate secrets with python3 -c "import secrets; print(secrets.token_hex(32))" and your password hash with docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password 'yourpassword'. For SSO into Audiobookshelf, also add the OpenID Connect provider block — see Audiobookshelf SSO.
  5. Cloudflare tunnel + DNS — see Remote access.
  6. Bring it up: ./start.sh (one command — Docker Desktop, docker compose up -d with self-healing, native Plex, wake-gate install, the launchd organizers, and a status summary).
  7. First-run each app (open the web UIs, create admin accounts, then record the generated API keys into .env) and wire them together per the pipeline sections below.

Starting / stopping

  • ./start.sh — idempotent full bring-up. It launches Docker Desktop (force-restarting it if the daemon is wedged), runs docker compose up -d and heals a partial bring-up (a corrupt container layer — "RWLayer … is unexpectedly nil" after an unclean shutdown — aborts compose midway and leaves the proxy down, which 502s the whole site; start.sh force-recreates the offenders and retries), starts native Plex, installs/loads the launchd organizers (music + audiobook), and deploys/installs the wake-gate (lets the Mac sleep without taking the site down; visitors get a "waking…" page while it self-wakes — see wake-gate/).
  • ./stop.sh — the reverse: stops the Spotify service, docker compose down, unloads the organizers, quits Plex, disables wake-gate, and quits Docker Desktop if nothing else needs it.

Services

App Purpose Host port In-container address
qBittorrent torrent client (movies/TV/audiobooks) 8080 (WebUI), 47828 (BitTorrent) qbittorrent:8080
slskd Soulseek client (music) 5030, 50300 slskd:5030
Prowlarr indexer manager 9696 prowlarr:9696
Radarr movie automation 7878 radarr:7878
Sonarr TV automation 8989 sonarr:8989
Lidarr retired — runs but empty/inert (see note) 8686 lidarr:8686
LazyLibrarian audiobook + ebook automation 5299 lazylibrarian:5299
Audiobookshelf audiobook server/player 13378 audiobookshelf:80
Kavita ebook reader/server (web + OPDS) 5001→5000 kavita:5000
Overseerr (Seerr) movie/TV requests + Plex Watchlist sync 5055 overseerr:5055
Ombi album request UI 3579 ombi:3579
Authelia single-login auth gate authelia:9091
proxy (nginx) reverse proxy in front of everything proxy:80
cloudflared Cloudflare Tunnel connector (dials out)

Networking: everything is on the default compose bridge. Cross-connect apps by container name, never localhost (inside a container localhost is that container). Prowlarr uses pinned public DNS (1.1.1.1/8.8.8.8). qBittorrent's WebUI has auth bypassed for the docker subnet, so containers reach it with blank credentials.

Lidarr is retired. It was wiped and taken out of the music pipeline after it monitored 1000+ artists, grabbed whole discographies, and permanently deleted files from /Music during failed "upgrades". The container still runs (empty: 0 artists/indexers/clients) and was removed from Prowlarr's app sync. Music now comes from the Soulseek liked-songs flow. Don't reconfigure Lidarr without setting a Recycle Bin first.

Secrets & .env

Nothing here contains real credentials. All input secrets and every app's generated admin/API key live in the git-ignored .env (one <APP>_USERNAME/PASSWORD/API_KEY group per service). Per-app config dirs (./radarr/, ./prowlarr/, ./authelia/, ./audiobookshelf/, ./lazylibrarian/, …) are git-ignored too — they hold live databases, API keys, and session data.

Never commit or paste a private-tracker key/passkey or mam_id anywhere outside the app itself. If one leaks, regenerate it from the tracker's security page and update the indexer.

Movies & TV pipeline

  1. Prowlarr → add your indexers (private only). Add Radarr and Sonarr as Applications (http://radarr:7878, http://sonarr:8989, Prowlarr URL http://prowlarr:9696) so indexers sync down automatically.
  2. Radarr / Sonarr → add qBittorrent as a download client: Host qbittorrent, Port 8080, blank user/pass (subnet-whitelisted).
  3. Overseerr → connect Plex, Radarr, Sonarr; enable Auto-Approve + Auto-Request for Movies and Series; set Plex Watchlist Sync to its 30s floor.

Add to Plex Watchlist → picked up within 30s → auto-requested → downloaded → imported.

Music pipeline

Music is your Spotify liked songs, downloaded lossless from Soulseek (no Lidarr, no VPN).

  1. Export your Spotify liked songs to a CSV at /Users/<you>/Downloads/spotify_liked_songs.csv (columns include title,artist,album,explicit,track_id,…).
  2. scripts/slskd_bulk_download.py searches slskd for each track and queues the best match:
    • Lossless only (FLAC/ALAC/APE/WAV/AIFF/WV) — lossy formats and the spotdl/Lidarr fallbacks are deliberately off, so a track with no lossless copy on Soulseek is recorded as no_match and retried on the next run.
    • Prefers the explicit/uncensored version of every track (penalises clean/radio-edit copies; hard-rejects clean copies for tracks Spotify flags explicit).
    • Verifies completion on re-run — a "queued" entry records the exact file, and a re-run re-attempts any that slskd later failed (rejected/errored/timed-out). Run it repeatedly to mop up misses; it's idempotent (state in scripts/slskd_bulk_download_state.json).
    • Runs in the background: nohup python3 scripts/slskd_bulk_download.py &, watch scripts/slskd_bulk_download.log.
  3. scripts/organize-soulseek-downloads.sh (launchd, every 60s) moves finished lossless folders from /Volumes/SSD/Plex/Soulseek into /Volumes/SSD/Plex/Music and triggers a Plex scan. It skips folders with no lossless audio so it never sweeps an audiobook into Music.

Verifying a run: "queued" ≠ downloaded — check slskd's real transfer states (GET localhost:5030/api/v0/transfers/downloads), and note that a slskd "Succeeded" file can still be a truncated FLAC — spot-check with flac -st. If the script appears to hang, slskd has usually lost its Soulseek login (server.state: Disconnected); docker restart slskd and it resumes. Soulseek throttles heavy searchers, so don't run extra searches while the bulk job runs.

There is also spotify-web/ (spotify.robertsinclair.me): a native macOS app that records a Spotify track straight into the Plex music library, built on the Core Audio capture engine in spotify-live/.

Audiobooks pipeline

LazyLibrarian ("Lidarr for audiobooks", the maintained Readarr replacement) → MyAnonaMouse → qBittorrent → Audiobookshelf.

1. Add MyAnonaMouse to Prowlarr. On myanonamouse.net → Preferences → Security → Create session locked to your server's public IP (or ASN for a dynamic home IP) → copy the long mam_id. In Prowlarr: Add Indexer → MyAnonamouse → paste mam_id → enable the Audiobooks categories. (The mam_id is a session cookie, not your announce passkey.)

2. qBittorrent category. Create a category audiobooks whose save path is a staging folder: /Volumes/SSD/Plex/Torrents/audiobooks (keeps torrents seeding after import).

3. Configure LazyLibrarian (lazylibrarian.robertsinclair.me) — its config.ini keys (sections UPPERCASE, keys lowercase; stop the container to hand-edit, or use the UI):

  • [WEBSERVER] http_proxy = 1required behind the proxy, or it emits http:// URLs on the https page and the browser blocks the search form as mixed content ("no results").
  • [API] ol_api = 1 and book_api = OpenLibrary — the metadata source must be enabled, or every search returns nothing. And [GENERAL] ol_url = https://openlibrary.org (no wwwwww.openlibrary.org redirects https→http and breaks the lookup).
  • [TORRENT] tor_downloader_qbittorrent = 1; [QBITTORRENT] qbittorrent_host = qbittorrent, qbittorrent_port = 8080, qbittorrent_label = audiobooks.
  • [GENERAL] audio_dir = /Volumes/SSD/Plex/Audiobooks, download_dir = /Volumes/SSD/Plex/Torrents/audiobooks, destination_copy = 1 (copy into the library so qBittorrent keeps seeding for your MAM ratio).
  • [Torznab_0] provider: enabled = True, host = http://prowlarr:9696/<indexerId> (LazyLibrarian appends /api), api = <Prowlarr API key>, audiocat = 3030, dltypes = A (audiobooks only). Only this provider — no Soulseek.

Use it: search an author → Add Audio on a book → LazyLibrarian grabs it from MAM → qBittorrent (staging) → LazyLibrarian's post-processor copies it into /Audiobooks → Audiobookshelf. Listen in the Plappa iOS app (the official ABS app is TestFlight-only; ShelfPlayer works too) — server https://audiobooks.robertsinclair.me, your login.

Ebooks pipeline

Same source and grabber, different library and reader. The MAM Torznab provider in LazyLibrarian is set to dltypes = A,E (audiobooks and ebooks) with bookcat = 7000,7020, and its ebook_dir = /Volumes/SSD/Plex/Books. Search a book → Add Book (ebook) → grabbed from MAM → qBittorrent → imported to /Books.

Kavita (books.robertsinclair.me) serves and reads them. It scans /Volumes/SSD/Plex/Books directly (no Calibre database needed — that's why Kavita over Calibre-Web). First run: register the admin (POST /api/account/register), then create a Book library (POST /api/Library/create, type: 2, fileGroupTypes: [1,2,3,4], folders: ["/Volumes/SSD/Plex/Books"]), and enable OPDS in Settings. Like Audiobookshelf it sits outside the Authelia forward-auth gate so OPDS works.

Read on iPhone — two ways:

  • Web reader: open books.robertsinclair.me in Safari, log in, Add to Home Screen. Fine for online reading.
  • OPDS (recommended, offline): in Kavita → Settings → 3rd Party Clients / your account, copy your OPDS URL (contains a per-user API key). Add it to an OPDS reader app — FBReader or PocketBook Reader (actively maintained; KyBook 3 is powerful but its updates have stalled). The app browses your library and downloads books for offline reading, syncing progress back to Kavita.

Remote access (Cloudflare Tunnel + Authelia)

Every hostname is served through the cloudflared tunnel → the proxy (nginx) container, which runs an auth_request against Authelia and redirects unauthenticated requests to auth.robertsinclair.me. Authelia's session cookie is scoped to the whole domain, so one login covers every service.

Hostname Backend
robertsinclair.me static landing page (landing/)
auth.robertsinclair.me Authelia portal (not gated)
plex.robertsinclair.me native Plex (host.docker.internal:32400)
spotify.robertsinclair.me spotify-web (host.docker.internal:5858)
overseerr / ombi / radarr / sonarr / lidarr / prowlarr / qbittorrent / slskd .robertsinclair.me the respective container
lazylibrarian.robertsinclair.me LazyLibrarian
audiobooks.robertsinclair.me Audiobookshelf — not behind the Authelia forward-auth gate (see SSO)
books.robertsinclair.me Kavita — not gated (OPDS needs direct access; Kavita's own login)

Adding a hostname needs two things: a DNS record and a tunnel ingress rule.

cloudflared tunnel route dns <TUNNEL_ID> <name>.robertsinclair.me      # DNS CNAME
python3 spotify-web/cf_add_hostname.py <name>.robertsinclair.me http://proxy:80   # tunnel ingress

Then add an nginx server block (copy an existing gated one) and recreate the proxy — see the mount gotcha below. The tunnel ingress is dashboard-managed (TUNNEL_TOKEN in .env); the tunnel id is 363cef6d-e2bc-4710-b8a3-62aa4c7d4b77.

Audiobookshelf single sign-on

Native audiobook apps (Plappa/ShelfPlayer) authenticate to the ABS API with a bearer token and can't pass a forward-auth cookie gate, so audiobooks.robertsinclair.me is intentionally not behind Authelia's auth_request. Instead ABS enforces its own login, wired to the same Authelia via OpenID Connect so it's still one identity:

  • Authelia configuration.yml gets an identity_providers.oidc block with a client audiobookshelf (redirect URIs https://audiobooks.robertsinclair.me/auth/openid/callback, …/auth/openid/mobile-redirect, audiobookshelf://oauth; scopes openid profile email groups). Generate an hmac_secret, a JWKS RSA key, and a hashed client secret (authelia crypto hash generate pbkdf2 --password …).
  • ABS → Settings → Authentication → OpenID Connect: issuer https://auth.robertsinclair.me, the client id/secret, auto-register, match by username.
  • The auth. and audiobooks. nginx blocks must send X-Forwarded-Proto https and X-Forwarded-Host — TLS ends at Cloudflare so nginx sees http, and both Authelia's OIDC and ABS's redirect-uri builder reject/mis-build otherwise.
  • For the Plappa app (username/password, not OIDC), keep the ABS local password in sync with your Authelia password via scripts/set-audiobook-password.sh (prompts, never echoes).

Operational gotchas (macOS-specific — read before recreating)

  • nginx edits don't apply on reload. nginx/default.conf is a single-file bind mount; on this host editing it changes the inode and the container keeps the old copy. After editing it (or the Authelia snippet), run docker compose up -d --force-recreate proxynot nginx -s reload. Symptom: a new hostname returns 200 (Authelia portal) instead of 302.
  • Audiobookshelf mounts the parent /Volumes/SSD/Plex, with its library folder set to /Volumes/SSD/Plex/Audiobooks. A subdirectory bind mount goes permanently stale here — files another container writes are invisible to ABS until it's recreated. ABS's file watcher also doesn't get host-side fs events over the bind mount, so imports appear on the next scan (LazyLibrarian/the organizer trigger POST /api/libraries/<id>/scan).
  • qBittorrent's BitTorrent port must stay out of 6881-6999. Private trackers (MyAnonaMouse) blacklist that whole range and reject the announce ("port 68xx is blacklisted"), so downloads stall with no peers. It's set to 47828 (TORRENTING_PORT env + published 47828:47828); forward that port on the router to this Mac for connectability/ratio.
  • Docker VM ≥ 4 GB RAM (a 1 GB VM OOM-crash-looped the whole stack).

Tests

Standalone bash in tests/ (no framework). Some assertions are stale after the VPN removal (they still expect flaresolverr / proxy on localhost:80); the organizer tests are current:

Script Checks Stack up?
tests/soulseek-organizer-test.sh music organizer: moves stable FLAC folders, skips active/.incomplete/existing/lossy No
tests/audiobook-organizer-test.sh audiobook organizer: moves stable MP3/M4B folders, skips lossless/active/existing No
tests/compose-config-test.sh compose parses; ${VAR}s documented (⚠ still expects removed flaresolverr) No
tests/nginx-config-test.sh nginx -t; every block except auth. gated No
tests/auth-gate-smoke-test.sh unauth → 302 to auth (⚠ expects proxy on localhost:80, unpublished here) Yes
tests/streaming-smoke-test.sh Plex streams a real movie/episode/track (needs PLEX_TOKEN) Yes

Known limitations / retention

  • Retention: Radarr/Sonarr per-indexer seed criteria — set Seed Ratio 0 for public trackers (removed after import) and leave it blank for private ones (seed forever, protect ratio). qBittorrent's global ratio limit stays unlimited.
  • LAN exposure: app WebUI ports are published on the host, so any device on the LAN reaches them with no auth (Authelia only gates the tunnel). Fine on a trusted network.
  • Watchlist sync is 30s polling (Plex has no watchlist webhook).
  • LazyLibrarian metadata is OpenLibrary (Goodreads' API is dead); catalogs can be thin — switch to Google Books (needs an API key) if a title is missing.

About

Self-hosted media automation stack behind SSO and a Cloudflare Tunnel — full runbook

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages