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.
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).
- 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 indocker-compose.ymlif 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.mezone, andcloudflared+wranglerCLIs. - 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).
- Clone this repo to the host that will run the stack.
.env—cp .env.example .envand 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.- Library folders:
mkdir -p "/Volumes/SSD/Plex/"{Movies,"Tv Shows",Music,Photos,Soulseek,Torrents,Audiobooks}
- Reverse-proxy + auth config (git-ignored, hold secrets):
Generate secrets with
cp authelia/configuration.yml.example authelia/configuration.yml cp authelia/users_database.yml.example authelia/users_database.yml
python3 -c "import secrets; print(secrets.token_hex(32))"and your password hash withdocker 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. - Cloudflare tunnel + DNS — see Remote access.
- Bring it up:
./start.sh(one command — Docker Desktop,docker compose up -dwith self-healing, native Plex, wake-gate install, the launchd organizers, and a status summary). - 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.
./start.sh— idempotent full bring-up. It launches Docker Desktop (force-restarting it if the daemon is wedged), runsdocker compose up -dand heals a partial bring-up (a corrupt container layer —"RWLayer … is unexpectedly nil"after an unclean shutdown — aborts compose midway and leaves theproxydown, 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 — seewake-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.
| 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
/Musicduring 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.
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.
- Prowlarr → add your indexers (private only). Add Radarr and Sonarr as Applications
(
http://radarr:7878,http://sonarr:8989, Prowlarr URLhttp://prowlarr:9696) so indexers sync down automatically. - Radarr / Sonarr → add qBittorrent as a download client: Host
qbittorrent, Port8080, blank user/pass (subnet-whitelisted). - 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 is your Spotify liked songs, downloaded lossless from Soulseek (no Lidarr, no VPN).
- Export your Spotify liked songs to a CSV at
/Users/<you>/Downloads/spotify_liked_songs.csv(columns includetitle,artist,album,explicit,track_id,…). scripts/slskd_bulk_download.pysearches 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_matchand 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 inscripts/slskd_bulk_download_state.json). - Runs in the background:
nohup python3 scripts/slskd_bulk_download.py &, watchscripts/slskd_bulk_download.log.
- 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
scripts/organize-soulseek-downloads.sh(launchd, every 60s) moves finished lossless folders from/Volumes/SSD/Plex/Soulseekinto/Volumes/SSD/Plex/Musicand 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/.
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 = 1— required behind the proxy, or it emitshttp://URLs on the https page and the browser blocks the search form as mixed content ("no results").[API] ol_api = 1andbook_api = OpenLibrary— the metadata source must be enabled, or every search returns nothing. And[GENERAL] ol_url = https://openlibrary.org(no www —www.openlibrary.orgredirects 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.
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.mein 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.
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 ingressThen 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.
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.ymlgets anidentity_providers.oidcblock with a clientaudiobookshelf(redirect URIshttps://audiobooks.robertsinclair.me/auth/openid/callback,…/auth/openid/mobile-redirect,audiobookshelf://oauth; scopesopenid profile email groups). Generate anhmac_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.andaudiobooks.nginx blocks must sendX-Forwarded-Proto httpsandX-Forwarded-Host— TLS ends at Cloudflare so nginx seeshttp, 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).
- nginx edits don't apply on reload.
nginx/default.confis 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), rundocker compose up -d --force-recreate proxy— notnginx -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 triggerPOST /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_PORTenv + published47828: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).
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 |
- Retention: Radarr/Sonarr per-indexer seed criteria — set Seed Ratio
0for 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.