PLAN-MediaServer.md — Mac Studio Media Stack Reconfiguration #
Handoff target: hermes (z-ai/glm-5.3-flash via Nous Portal) Prepared: 2026-09-09 · Status: v1.2 — Phase 1-3 partially executed; Phase 4 documentation appended Changelog v1.2 (2026-09-10): Domain strategy changed by owner: Option A (Cloudflare NS) REJECTED → Option 4 selected (Tailscale Funnel, no DNS changes). Phase 4 (docs + reproduction prompt) appended. Setup ~80% complete — see §4.8 snapshot and §4.6 gaps. Changelog v1.1: Domain Option A selected by owner (Porkbun → Cloudflare NS move); §11 rewritten as cutover runbook; §17/§18 updated. One TODO remains: WHICH domain (§11 step 2). Golden rule: Plex + Jellyfin stay native (VideoToolbox HW transcode, existing libraries/watch states untouched). Everything else goes into Docker (OrbStack). Torrent traffic is isolated in a gluetun container using Mullvad WireGuard.
0. RESOLVED DECISIONS (owner, 2026-09-09) #
| Area | Decision |
|---|---|
| Hardware | Mac Studio 2022, M1 Max, 32 GB RAM, macOS 26.6.2 |
| Docker runtime | OrbStack (not installed yet) — free for personal use, ~3–5 s boot, ~150–300 MB idle RAM, drop-in Docker engine. Fallback: Docker Desktop. NOT colima (more manual, no GUI). |
| Volume | /Volumes/photos library iCloud/Media — APFS, always mounted, ~1 TB free of 2 TB. Also holds the iCloud Photos library — nothing may scan or write outside the paths listed in §5. |
| Size policy | 1080p only; prefer HEVC/x265 releases; compress existing library with Tdarr; minimize read/writes on external (use internal SSD as working space) |
| qBittorrent | Containerized behind gluetun (Mullvad WireGuard). Mullvad desktop app becomes unnecessary on this Mac — disable/uninstall it. |
| Requests | Seerr with Plex auth (NOT Ombi — Ombi's development stalled; Seerr is the merged, actively-patched Overseerr+Jellyseerr) |
| Audience | Friends & family; public face = Plex + Seerr |
| Domain | OPTION A SELECTED — move one domain's nameservers from Porkbun to Cloudflare (registrar stays Porkbun). Which domain = TODO §11. |
| CGNAT | Unknown — agent must detect (§11 step 0) |
| Jackett | Capture indexer names, then brew uninstall --zap jackett; re-add indexers fresh in Prowlarr |
| Scope | Movies + TV now; Lidarr (music) included; Homarr, Decluttarr, Tdarr all wanted (Phase 3) |
| Updates | Containers Sundays 06:00; brew weekly Sundays 06:15; macOS monthly |
| Notifications | Discord webhooks; Bluesky posting deferred to v2 |
| 2FA | Required on public-facing UIs (Cloudflare Access + Seerr TOTP + Plex account 2FA) |
1. GOALS #
- Remote streaming (Plex primary, Jellyfin secondary) + friend/family requests without open router ports.
- All torrent traffic (download and seed) exits via Mullvad WireGuard, with a true kill switch.
- Automated rename/organize of Movies & TV so both Plex and Jellyfin parse cleanly.
- Minimal storage growth: 1080p caps, HEVC preference, Tdarr compression of existing library.
- Minimal management: auto-updates, backups, log rotation; fully agent-operable.
2. WHY NOT EVERYTHING IN DOCKER (rationale for the hybrid) #
- No VideoToolbox in the Linux VM. Docker/OrbStack containers run in a Linux VM that cannot reach macOS's VideoToolbox media engine — the reason native Plex/Jellyfin hardware-transcode is fast and containerized playback transcode would be CPU-bound. (Same reason Tdarr's node runs native, §10.)
- File-sharing boundary. The external APFS volume is shared into the VM via VirtioFS: extra copy/sync layer, PUID/PGID + ownership quirks, historically flaky hardlink/mmap behavior. Streaming servers read big files continuously — native is faster and simpler.
- Risk. Migrating existing Plex/Jellyfin libraries, watch states, and the co-located Photos library into container paths buys nothing and risks breakage.
- Everything else (automation, downloads, requests, dashboards) has none of these constraints and gains from declarative compose + auto-updates → those go in Docker.
3. CURRENT STATE (verify at start) #
- Native apps: Plex, Jellyfin, qBittorrent (WebUI :8080), Jackett (:9117, via Homebrew)
- Mullvad desktop app w/ split tunneling (will be retired on this Mac, §12)
- No Docker/OrbStack; domains at Porkbun; Cloudflare account exists but unused
- Media root:
/Volumes/photos library iCloud/Media(existingMovies,TVdirs — do not rename)
4. TARGET ARCHITECTURE #
Remote users ─▶ https://requests.<domain> (Cloudflare Tunnel) ─▶ Seerr :5055
Remote users ─▶ Plex (relay or port-forward, §11) ; optional media.<domain> ─▶ Jellyfin :8096
Owner/admin ─▶ Tailscale ─▶ every UI (arrs, qbit, dozzle, tdarr, homarr)
LAN ─▶ everything
Seerr (Plex auth) ─▶ Radarr / Sonarr / Lidarr [Docker]
│ indexers from Prowlarr [Docker] (replaces Jackett)
▼
qBittorrent [Docker] ── gluetun [Docker] ── Mullvad WireGuard
│ incomplete: internal SSD → complete: /data/Downloads/{movies,tv}
▼
Radarr/Sonarr/Lidarr import + rename ─▶ /data/{Movies,TV,Music}
▼
Bazarr subtitles ─▶ Jellyfin (realtime scan) + Plex (periodic scan)
Tdarr server [Docker] + Tdarr node [NATIVE macOS, VideoToolbox] → shrink existing library
5. STORAGE & QUALITY STRATEGY (minimum footprint) #
/Volumes/photos library iCloud/Media/ ← external 2 TB (1 TB free)
├── Downloads/{movies,tv,music} ← completed torrents land here (hardlink source)
├── Movies/ TV/ Music/ ← libraries (Movies/TV exist; Music created)
Internal SSD (check free space, T0): if ≥ 150 GB free →
~/arrstack/incomplete/{movies,tv} ← qBittorrent incomplete dir (absorbs download writes)
~/arrstack/tdarr-cache ← Tdarr transcode temp
- Hardlinks (same volume): Radarr/Sonarr import via hardlink from
/data/Downloads→/data/Movies|TV— instant, zero extra space. Verify withstat -f %l; if the VirtioFS boundary refuses, set "Use Hardlinks = No" (copy fallback) and note it. - Quality caps: Radarr/Sonarr = 1080p max (no 4K/2160p). Prefer WEB-DL HEVC/x265 via custom formats; apply TRaSH quality profiles through Recyclarr (small-size oriented).
- Lidarr: lossy profile (MP3 320 kbps preferred; FLAC rejected to keep size down).
- qBittorrent: categories
movies/tv; incomplete → internal SSD; finished →/data/Downloads/{category}(one sequential write to the external, then hardlink imports). Pre-allocate = off (avoids pre-writing). - Decluttarr (Phase 3): deletes torrents from qBittorrent only after import + seed goals met — keeps Downloads lean while still seeding.
- Tdarr (Phase 3): convert existing 1080p H.264 high-bitrate files to HEVC via VideoToolbox on the native node. Temp/cache on internal SSD; final write is a single replace in place on the external.
- Photos library safety: Tdarr/qbit/arr libraries point ONLY at
/data/Movies,/data/TV,/data/Downloads,/data/Music. Never scan the volume root.
6. NAMING STANDARD (parses cleanly in Plex AND Jellyfin) #
- Movies:
Movie Title (Year) {tmdbid-000000}/ fileMovie Title (Year) - Quality.group - TV:
Show Title (Year) {tvdbid-00000}/Season 01/Show Title - S01E01 - Episode Name - Music (Lidarr default):
Artist/Album (Year)/Track - Title - Radarr/Sonarr/Lidarr rename + move enabled; Plex & Jellyfin point at
Movies/,TV/,Music/.
7. DOCKER STACK — ~/arrstack/ #
Config under ~/arrstack/config/<app>; gluetun secrets in ~/arrstack/.env (chmod 600, never commit).
7.1 docker-compose.yml (Phase 1 core) #
name: arrstack
services:
gluetun: # VPN sidecar — torrent traffic ONLY exits via Mullvad
image: qmcgaw/gluetun:latest
cap_add: [NET_ADMIN]
devices: [/dev/net/tun:/dev/net/tun]
environment:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${MULLVAD_WG_PRIVATE_KEY}
- WIREGUARD_ADDRESSES=${MULLVAD_WG_ADDRESS} # e.g. 10.66.x.x/32
- SERVER_COUNTRIES=${MULLVAD_COUNTRY} # e.g. United States
volumes: ["./gluetun:/gluetun"]
ports: ["8080:8080"] # qBittorrent WebUI rides gluetun's netns
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
network_mode: "service:gluetun" # ← kill switch: container has NO route without VPN
depends_on: {gluetun: {condition: service_healthy}}
environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}, WEBUI_PORT=8080]
volumes:
- ./config/qbittorrent:/config
- "${HOME}/arrstack/incomplete:/internal" # working space on internal SSD
- "${MEDIA_ROOT}:/data" # completed + libraries
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}]
volumes: ["./config/prowlarr:/config"]
ports: ["9696:9696"]
restart: unless-stopped
labels: {com.centurylinkfoundation.watchtower.enable: "true"}
radarr:
image: lscr.io/linuxserver/radarr:latest
environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}]
volumes: ["./config/radarr:/config", "${MEDIA_ROOT}:/data"]
ports: ["7878:7878"]
restart: unless-stopped
labels: {com.centurylinkfoundation.watchtower.enable: "true"}
sonarr:
image: lscr.io/linuxserver/sonarr:latest
environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}]
volumes: ["./config/sonarr:/config", "${MEDIA_ROOT}:/data"]
ports: ["8989:8989"]
restart: unless-stopped
labels: {com.centurylinkfoundation.watchtower.enable: "true"}
lidarr:
image: lscr.io/linuxserver/lidarr:latest
environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}]
volumes: ["./config/lidarr:/config", "${MEDIA_ROOT}:/data"]
ports: ["8686:8686"]
restart: unless-stopped
labels: {com.centurylinkfoundation.watchtower.enable: "true"}
bazarr:
image: lscr.io/linuxserver/bazarr:latest
environment: [PUID=${PUID}, PGID=${PGID}, TZ=${TZ}]
volumes: ["./config/bazarr:/config", "${MEDIA_ROOT}:/data"]
ports: ["6767:6767"]
restart: unless-stopped
labels: {com.centurylinkfoundation.watchtower.enable: "true"}
seerr: # unified Overseerr+Jellyseerr successor
image: ghcr.io/seerr-team/seerr:latest
init: true
environment: [TZ=${TZ}] # NOTE: Seerr ignores PUID/PGID by design
volumes: ["./config/seerr:/app/config"]
ports: ["5055:5055"]
restart: unless-stopped
labels: {com.centurylinkfoundation.watchtower.enable: "true"}
watchtower: # maintained fork; containrrr original archived 2025-12-17
image: ghcr.io/nicholas-fedor/watchtower:latest
command: --label-enable --cleanup --schedule "0 0 6 * * 0" # Sundays 06:00 local
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
restart: unless-stopped
dozzle:
image: amir20/dozzle:latest
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
ports: ["127.0.0.1:8081:8080"] # localhost only
restart: unless-stopped
# Phase 3 adds: tdarr (server :8265/:8266), recyclarr, unpackerr, decluttarr, homarr,
# flaresolverr (:8191, only if an indexer needs it)
7.2 ~/arrstack/.env #
PUID=501
PGID=20
TZ=America/<City>
MEDIA_ROOT="/Volumes/photos library iCloud/Media"
MULLVAD_WG_PRIVATE_KEY=<base64 key from generated Mullvad WireGuard config>
MULLVAD_WG_ADDRESS=<Address value from same config, e.g. 10.66.x.x/32>
MULLVAD_COUNTRY=<nearest Mullvad country>
Mullvad note: the private key is NOT the "WireGuard Key" on the Devices page — generate a WireGuard config file from the Mullvad account page and extract PrivateKey + Address.
7.3 Exposure matrix #
| Service | Port | Reachable from |
|---|---|---|
| Seerr | 5055 | LAN + Tailscale + public (tunnel, behind Cloudflare Access) |
| Radarr/Sonarr/Lidarr/Prowlarr/Bazarr | 7878/8989/8686/9696/6767 | LAN + Tailscale |
| qBittorrent WebUI | 8080 | LAN + Tailscale (WebUI bound in gluetun netns) |
| Plex | 32400 | LAN + public (relay or port-forward) |
| Jellyfin | 8096 | LAN + Tailscale (+ optional tunnel) |
| Tdarr web | 8265 | LAN + Tailscale |
| Dozzle | 8081 | localhost only |
8. SETUP ORDER #
df -h /→ confirm internal free space (branch §5).- Install OrbStack:
brew install --cask orbstack; enable start-at-login; verifydocker compose version. - Create folder layout (§5): Downloads, incomplete dirs, Music dir.
cd ~/arrstack && docker compose up -d.- qBittorrent: add categories
movies,tvwith save paths/data/Downloads/movies,/data/Downloads/tv; incomplete →/internal/{movies,tv}; on-completion move enabled; WebUI strong password + localhost binding. - Prowlarr: re-add the indexers captured from old Jackett config; Settings→Apps→
Radarr + Sonarr (+Lidarr) full sync. Then
brew uninstall --zap jackett. - Radarr: root folder
/data/Movies; download client qBittorrent athttp://gluetun:8080categorymovies; rename on; hardlinks per §5. - Sonarr: root folder
/data/TV; categorytv; same settings. - Lidarr: root folder
/data/Music; quality = lossy 320. - Bazarr: providers OpenSubtitles.com (API key) + Podnapisi; connect to radarr/sonarr by service name.
- Seerr: auth = Plex; import Plex users; connect Radarr/Sonarr/Lidarr by service
name (
http://radarr:7878etc.); approvals ON for non-admins; enable 2FA (TOTP) for owner. - Plex/Jellyfin: Plex "Update library periodically" 15 min; Jellyfin realtime monitoring on.
- Recyclarr (Phase 3) syncs TRaSH 1080p/HEVC profiles into Radarr/Sonarr.
9. macOS SERVICE PERSISTENCE #
- OrbStack: start-at-login ON → containers (
restart: unless-stopped) auto-start at boot. - Native: Plex.app, Jellyfin.app in Login Items; Tdarr node app auto-launch via LaunchAgent;
cloudflared + tailscale as login services (
cloudflared service install, Tailscale app login). - Power:
sudo pmset -a sleep 0 disksleep 0 autorestart 1— keep server awake; keep media volume awake.
10. TDARR COMPRESSION (Phase 3) — "smallest 1080p, low read/write" #
- Server in Docker (
ghcr.io/haveagitgat/tdarr), ports 8265 (web)/8266 (comm). - Node runs NATIVE on the Mac Studio (official Tdarr macOS node build) — native nodes
can use VideoToolbox HEVC (the bundled M1 ffmpeg encodes ~180 fps vs ~2 fps for
software transcodes). Plugin:
Tdarr_Plugin_ER01_Transcode audio and video with HW (PC and Mac)or the Boosh HEVC flow (routes to VideoToolbox on macOS). - Node temp/cache →
~/arrstack/tdarr-cache(internal SSD). Source/output stay on the external library — one replace-in-place write per file. - Target: HEVC 1080p CRF ~22–24 (VideoToolbox), audio pass-through where sensible.
- Exclude any folder outside
/data/Movies|TV|Downloadsfrom all Tdarr libraries.
11. REMOTE ACCESS + DNS CUTOVER (OPTION A SELECTED) #
Step 0 — CGNAT check (agent):
curl -s https://api.ipify.org # compare to router WAN IP in admin UI
# If WAN starts with 100.64–100.127 (100.64.0.0/10), or router WAN ≠ public IP → CGNAT
- Not CGNAT: optionally port-forward 32400 → best Plex remote quality; still use tunnels below.
- CGNAT (assume): Plex relay (quality-limited) + tunnels for everything else.
Domain: OPTION A SELECTED — move the chosen Porkbun domain's nameservers to Cloudflare (free plan). Porkbun remains the registrar; only NS changes. This affects the WHOLE domain. MANDATORY ORDER OF OPERATIONS (hermes):
- Owner confirms WHICH Porkbun domain →
requests.<domain>hostname. ← TODO, blocks Phase 2 - Inventory ALL existing DNS records in Porkbun (screenshot/export; check for mail/MX, SPF, DKIM, existing sites).
- If live records exist and can't be recreated → STOP, escalate to owner (Option B fallback below).
- Recreate every record in Cloudflare (verify:
dig @<cf-nameserver> <record>before cutover). - Get the 2 assigned Cloudflare nameservers; change NS at Porkbun (Account → Domain Management → Nameservers → "Use custom nameservers (enter NS)").
- Wait for propagation (up to 24–48 h; often <1 h); verify
dig NS <domain>; then create the Cloudflare Tunnel + hostnames + Access policies. Fallback (Option B): leave DNS at Porkbun entirely; publish Seerr via Tailscale Funnel (https://<machine>.<tailnet>.ts.net) — free, zero DNS changes. Use if domain has live records/dependencies that can't safely move. Option C (subdomain-only partial/CNAME setup) requires Cloudflare Business — dropped.
Tunnel setup: brew install cloudflared → Zero-Trust dashboard → tunnel
media-stack → cloudflared service install <token> (runs at login/boot).
Routes: requests.<domain> → http://localhost:5055;
media.<domain> → http://localhost:8096 (optional, light use only).
Tailscale: brew install --cask tailscale, join tailnet; owner devices get full
admin reach to every UI. Public surface = Seerr (+Plex) ONLY — arrs/qbit/dozzle never public.
12. VPN POLICY #
- gluetun = the kill switch (container has no network path except WireGuard; gluetun's firewall drops everything on tunnel loss). qBittorrent shares its netns → all torrent traffic (download + seed) exits Mullvad.
- Verify: qBittorrent torrent session IP must be a Mullvad IP (T2).
- Retire the Mullvad desktop app on the Studio (disable autostart or uninstall; keep the account). If kept running, exclude OrbStack/Docker from its tunnel to avoid double-tunneling gluetun.
- Mullvad has no port forwarding (dropped 2023) → seeding is connectable-outgoing only; ratio goals may be limited. Accepted limitation; revisit only if ratio matters.
13. UPDATES (owner: Sundays 06:00) #
| Component | Method | Cadence |
|---|---|---|
| All labeled containers | Watchtower fork | Sunday 06:00 (0 0 6 * * 0) |
| OrbStack, cloudflared, tailscale | brew upgrade via launchd script (log to ~/arrstack/logs) |
Sunday 06:15 |
| Plex | built-in auto-update | auto |
| Jellyfin | built-in updater | monthly |
| Tdarr node | manual check | monthly |
| macOS | softwareupdate -i -R via launchd |
monthly |
Maintenance: Docker log rotation in daemon.json (max-size: 10m, max-file: 3); |
||
monthly docker system prune -f; disk alert at 90% (df -h). |
14. BACKUPS (configs only; media is re-downloadable) #
Weekly launchd rsync (after updates, ~06:30) → ~/Backups/arrstack/, keep 4 generations:
~/arrstack/ (compose, .env, config/), Plex Preferences.xml, Jellyfin data dir,
Tdarr node config. Restore drill = T10.
15. SECURITY (2FA REQUIRED) #
- Cloudflare Access (email OTP) in front of every public hostname before it goes live.
- Seerr: TOTP 2FA on owner account; Plex account 2FA enabled; no open signups; approvals required.
- Cloudflare account + Porkbun: 2FA ON.
- qBittorrent WebUI: strong password; bind WebUI to LAN/Tailscale only.
- Secrets only in
.env/Keychain; never in git, plan files, or screenshots.
16. ACCEPTANCE TESTS #
| # | Test | Pass |
|---|---|---|
| T0 | df -h / recorded; internal-space branch decided |
logged |
| T1 | docker compose ps all Up/healthy; gluetun healthy |
logged |
| T2 | Torrent session IP = Mullvad (gluetun public-ip check) | logged |
| T3 | Kill switch: docker stop gluetun → qBittorrent loses connectivity; restart recovers |
logged |
| T4 | End-to-end: Seerr request → arr grabs → qbit (VPN) → import/rename → appears in Plex AND Jellyfin | logged |
| T5 | Bazarr pulls subtitles for a fresh import | logged |
| T6 | Hardlink verified (stat -f %l > 1) or copy-mode fallback explicitly logged |
logged |
| T7 | Phone LTE: requests.<domain> loads (Access OTP), Plex streams, Jellyfin streams if exposed |
logged |
| T8 | Watchtower: forced run updates a labeled container, stack recovers | logged |
| T9 | Cold reboot: OrbStack, compose services, native apps, cloudflared, tailscale all auto-start | logged |
| T10 | Weekly backup exists; one config restored successfully | logged |
| T11 | Tdarr: one movie transcoded to HEVC via VideoToolbox, plays in both servers | logged |
17. HERMES RUNBOOK #
- Stack root
~/arrstack/→docker compose ps|logs -f <svc>|restart <svc>|docker compose pull && docker compose up -d - Logs UI: Dozzle
127.0.0.1:8081. Native apps: Plex.app, Jellyfin.app, Tdarr node (Login Items). - gluetun is the VPN: NEVER run qBittorrent alone; it rides gluetun's network.
- DNS cutover (§11) is a one-time, owner-visible event. NEVER switch nameservers before every existing record is recreated and verified in Cloudflare. Never touch DNS records for other domains.
- Mullvad WireGuard creds live in
~/arrstack/.envonly. Never move/scan files outside/data/Movies|TV|Music|Downloads. Never expose arr UIs publicly. Never rename Plex/Jellyfin library paths while servers run. - Escalate to owner: library reorganization, domain/NS changes, new public URLs, any new service beyond the Phase tables, deleting any media.
18. PHASES #
- P1 Core: OrbStack + compose (gluetun/qbit/prowlarr/radarr/sonarr/lidarr/bazarr/seerr/watchtower/dozzle) + indexers + qbit categories → T0,T1,T2,T3,T5,T6 ✅ (2026-09-10)
- P2 Remote:
DNS cutover(owner rejected Option A → Tailscale Funnel instead, plan §4.1 #5) → Tailscale install+login ✅ → Funnel live: https://mac-studio-2022.tail09c327.ts.net → Seerr :5055 (persistent,funnel --bg) → remaining: Seerr Plex-auth wizard + 2FA (owner, browser) → T4,T7 - P3 Optimization & extras: Tdarr server+native node, Recyclarr (TRaSH 1080p/HEVC), Unpackerr, Decluttarr, Homarr dashboard, Discord webhooks, backups+launchd → T8,T9,T10,T11 (partially done — see §4.8)
- P4 Music expansion: Lidarr indexers/quality, Music library in both servers (Plex Music lib creation deferred until content exists — empty dir fails)
- P5 Optional/ask owner: Wizarr invites, cross-seed, FlareSolverr, Notifiarr
18b. PHASE 5 — ENHANCEMENTS (future, owner-approved ideas) #
5.1 Custom domain for requests page (replaces rr.psingletary.com via Funnel limitation) #
Funnel only serves *.ts.net names — custom domains are impossible on Funnel.
When owner wants a branded URL (e.g. rr.<cheap-domain>):
- Register a cheap domain (~$3–10/yr) directly in Cloudflare (registrar = Cloudflare; psingletary.com at Porkbun stays untouched).
brew install cloudflared→ Zero-Trust dashboard → tunnelmedia-stack→cloudflared service install <token>.- Route
rr.<domain>→http://localhost:5055(Seerr), behind Cloudflare Access email-OTP (plan §15). - Disable Funnel (
tailscale funnel --https=443 off) or keep both (Funnel as fallback URL). - Tailscale stays for owner/admin reach regardless.
5.2 Plex remote quality upgrade #
Plex relay (current) is quality-limited. Not CGNAT (108.39.35.247 confirmed) → optional router port-forward 32400 → best remote quality. Owner decision; requires router admin access + static-IP/DDNS awareness.
5.3 Other candidates (from plan §19 + sessions) #
- Wizarr invite links for friends/family onboarding
- Cross-seed for existing library (ratio building)
- FlareSolverr container → unblocks eztv/1337x/KAT in Prowlarr
- Notifiarr/Discord richer notifications (P3 Discord webhooks baseline first)
- Tailscale SSO via ATProto OIDC (experimental, plan §19.1)
- Bluesky new-arrival posting (plan §19.2)
19. ADDENDUM v2 (post-setup, owner-approved ideas) #
- Tailscale SSO via Bluesky (ATProto): Tailscale supports custom OIDC IdPs (needs a
WebFinger endpoint on a domain you control). ATProto provides OAuth/OIDC-style login; a
bridge exists —
apenwarr/atlogin("OIDC provider that logs into a Tailscale network using an ATProto/Bluesky identity"; experimental) — ortailscale/tsidpfor OIDC into tailnet apps. Treat as experimental; do NOT make it the only auth path. Keep Cloudflare Access on public URLs regardless. - Bluesky posting: small local service listening to Radarr/Sonarr/Lidarr "Discord custom" webhooks → posts new-arrival notices to a Bluesky handle via the ATProto API.
- Optional: Plex Pass trial to evaluate port-forward quality vs relay.
20. REFERENCES #
- https://github.com/Wh1rr/ultimate-jellyfin-stack (compose patterns, hardlink rule)
- https://jellywatch.app/blog/jellyfin-full-automation-guide-radarr-sonarr-bazarr-jellyseerr-2026
- https://corelab.tech/arr-stack-docker-compose-guide/
- https://docs.seerr.dev/ (Seerr = merged Overseerr+Jellyseerr; Plex auth; ghcr.io/seerr-team/seerr)
- https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/mullvad.md (Mullvad WireGuard)
- https://orbstack.dev/ (runtime; free personal use)
- https://github.com/HaveAGitGat/Tdarr (+ macOS node VideoToolbox notes)
- https://tailscale.com/docs/integrations/identity/custom-oidc (v2)
- https://developers.cloudflare.com/dns/zone-setups/partial-setup/ (why Option A/B, not C)
PLAN-MediaServer — PHASE 4: DOCUMENTATION & REPRODUCIBILITY #
Added: 2026-09-10 (owner request) Goal: Complete record of every decision, install, script, and action taken during this setup, plus a ready-to-run prompt for reproducing this stack on another macOS device (Apple Silicon or Intel, macOS 13+). Audience: Owner + any agent (Hermes or human) doing a fresh install.
4.1 MASTER DECISION LOG (chronological, owner-confirmed) #
| # | Decision | Choice | Rationale / Notes |
|---|---|---|---|
| 1 | Scope | Standalone infra reconfiguration on Mac Studio 2022 — NOT a Tangled repo project | Supersedes plan-intake flow in _shared/AGENTS.md |
| 2 | Container runtime | OrbStack (brew install --cask orbstack) |
Free personal use, ~3-5 s boot, drop-in Docker engine; NOT colima/Docker Desktop |
| 3 | Media root | /Volumes/photos library iCloud/Media (APFS external, 2 TB) |
Existing Movies/TV dirs kept; Photos library co-located — never scan/write outside Movies/TV/Music/Downloads |
| 4 | Public request hostname | rr.psingletary.com via Seerr :5055 |
Owner-chosen |
| 5 | Domain strategy | Option 4: Tailscale Funnel — NO DNS changes to psingletary.com | Owner rejected Cloudflare NS handover; Cloudflare partial/CNAME setup requires Business plan; Funnel = free TLS, zero DNS risk |
| 6 | VPN for torrents | gluetun container + Mullvad WireGuard (config file, "Mellow Snake" device) | Kill-switch verified (T3); Mullvad desktop app still running on host — must exclude OrbStack VM from its tunnel or retire app |
| 7 | Mullvad WG creds | PrivateKey + Address 10.67.25.130/32 from generated zip; stored ONLY in ~/arrstack/.env (chmod 600) |
"Sharp Cat" key superseded by "Mellow Snake" |
| 8 | Plex | Fresh init; auto-claimed as patrick.singletary+plex@gmail.com on first launch |
Libraries added via API (Movies, TV); Music deferred to P4 |
| 9 | qBittorrent | Containerized behind gluetun; 2 legacy native-app torrents re-added via API | RESOLVED: both relocated to reorg paths, rechecked 100%, seeding (Ted Lasso + Mr. Robot S01-S04) |
| 10 | Jackett | brew services stop + brew uninstall --zap after Prowlarr up |
Had zero indexers — nothing to migrate |
| 11 | Indexers | yts, thepiratebay, limetorrents (public) via Prowlarr API | eztv/1337x/KAT blocked by Cloudflare → need FlareSolverr (P5 optional) |
| 12 | Quality policy | 1080p cap; custom "WEB-1080p" profile synced by Recyclarr to Radarr+Sonarr | TRaSH quality sizes applied; upgrade until WEB 1080p |
| 13 | Library reorg | Radarr/Sonarr ManualImport (move mode) + scripted folder restructure | See 4.3 actions; nothing deleted — extras quarantined |
| 14 | Updates | Watchtower fork Sundays 06:00; brew launchd 06:15; backups 06:30 | launchd jobs loaded |
| 15 | Media moves | Approval-gated. Bulk import approved once; empty-dir deletes blocked by security gate → files moved to quarantine dirs instead | Deletion is always a separate explicit owner task |
4.2 INSTALLED SOFTWARE (exact list) #
Homebrew:
- Cask:
orbstack - Formula (already present):
tailscale(CLI),jackett(REMOVED via--zap)
Direct download (bypasses cask's sudo requirement):
- Tailscale.app ←
https://pkgs.tailscale.com/stable/Tailscale-latest-macos.zip- Installed to
/Applications/Tailscale.app; quarantine xattr cleared - Login = owner manual step (menu-bar icon)
- Installed to
Docker images (via compose): qmcgaw/gluetun, lscr.io/linuxserver/{qbittorrent,prowlarr,radarr,sonarr,lidarr,bazarr}, ghcr.io/seerr-team/seerr, ghcr.io/nicholas-fedor/watchtower, amir20/dozzle, ghcr.io/recyclarr/recyclarr:8 (NOT :latest — does not exist), ghcr.io/unpackerr/unpackerr, ghcr.io/haveagitgat/tdarr
Native macOS apps (pre-existing): Plex Media Server (auto-updates), Jellyfin.app, qbittorrent.app (native instance still present — retire after container migration complete)
4.3 FILESYSTEM & CONFIG ARTIFACTS #
~/arrstack/ ← stack root (all config backed up weekly)
├── docker-compose.yml ← 13 services (gluetun→tdarr)
├── .env ← chmod 600: PUID=501 PGID=20 TZ, MEDIA_ROOT, MULLVAD_WG_* (secret)
├── config/<app>/ ← per-app persistent config
├── config/recyclarr/recyclarr.yml + includes/ ← v8 syntax (see 4.5 gotchas)
├── incomplete/{movies,tv,music}/ ← qbt temp dir (internal SSD; T0: 209 GB free passed ≥150 GB branch)
├── tdarr-cache/ ← transcode temp (internal SSD)
├── scripts/
│ ├── backup-arrstack.zsh ← weekly zip → ~/Backups/arrstack/, keep 4
│ └── brew-upgrade.zsh ← weekly cask/formula upgrades
└── logs/ ← launchd + backup logs
~/Backups/arrstack/ ← arrstack-backup-YYYY-MM-DD_HHMM.zip (keep 4)
~/Library/LaunchAgents/
├── com.psingletary.arrstack-backup.plist (Sun 06:30)
└── com.psingletary.arrstack-brew-upgrade.plist (Sun 06:15)
~/.orbstack/config/docker.json ← log rotation: max-size 10m, max-file 3
<Volume>/Media/ ← media root
├── Movies/ ← reorganized: Title (Year)/Title (Year) Quality.ext
├── TV/ ← reorganized: Series/S01|Season 01/episodes
├── Music/ ← empty (Lidarr P4)
├── Downloads/{movies,tv,music}/← qbt completed (hardlink source)
└── Movie-subtitles-and-extras/ ← QUARANTINE: 82 leftover dirs (srt/jpg/txt only) awaiting owner review — DO NOT scan in any media server
Keychain: qbittorrent-webui ← WebUI password (generic password, user patricksingletary)
4.4 API-BASED CONFIGURATION RECIPE (all verified working) #
Order matters. Allarr/s are HTTP on localhost ports; API keys read from <app>/config/config.xml <ApiKey>.
- qBittorrent (via WebUI API, cookie auth
/api/v2/auth/login):- Categories
movies→/data/Downloads/movies,tv→/data/Downloads/tv, bothdownload_path=/internal - Global:
save_path=/data/Downloads,temp_path_enabled=true,temp_path=/internal - WebUI password set + stored in Keychain
- Legacy torrents: POST .torrent files →
torrents/add(category=tv) →torrents/setLocation(match torrent's internal folder structure!) →torrents/recheck
- Categories
- Prowlarr (
/api/v1/): add indexer defs (definitionName from/api/v1/indexer/schema, must setappProfileId: 1), then applications (Radarr/Sonarr/Lidarr,syncLevel=fullSync, PURLs use service names) → indexers auto-push to apps - Radarr/Sonarr/Lidarr (
/api/v3/or/api/v1/for Lidarr): root folders first (defaultMetadataProfileIdREQUIRED for Lidarr), download client (hostgluetun, port 8080), naming config (rename on + format strings) - Bazarr: settings-API POST is silently ignored for radarr/sonarr sections → stop container, edit
config/config/config.yamldirectly (set ip=radarr/sonarr service names + apikeys,use_radarr/use_sonarr: true), restart; verify via/api/system/status→radarr_versionnon-empty - Recyclarr v8:
quality_definition.type+ include files inconfig/recyclarr/includes/; profile syntax =upgrade: {allowed, until_quality, until_score}+qualities:list.docker compose run --rm recyclarr synconce, then @daily cron runs - Unpackerr: env vars are INDEXED:
UN_SONARR_0_URL,UN_SONARR_0_API_KEY,UN_SONARR_0_PATHS(plainUN_SONARR_URLis silently ignored) - Plex libraries (
http://127.0.0.1:32400, token from~/Library/Application Support/Plex Media Server/.LocalAdminToken, headerX-Plex-Client-Identifier: hermes-setup):POST /library/sections?name=Movies&type=movie&agent=com.plexapp.agents.imdb&scanner=Plex%20Movie%20Scanner&language=en&location=<path>— language=en (NOT en-US); paths with spaces work- TV:
agent=com.plexapp.agents.thetvdb+scanner=Plex%20Series%20Scanner - Music: could NOT create with any agent/scanner combo on empty dir — defer until content exists
- Scan:
GET /library/sections/{id}/refresh; scan takes ~7 min for 84 movies + 23 shows
- Library reorg (the big one): register all series/movies via lookup API first (
monitored:false, addOptions.searchForMovie:false) →PUT series/{id}path fixes → ManualImport command{"name":"ManualImport","files":[{path, movieId, quality,...}],"importMode":"move"}(Radarr; Sonarr auto-detected after path fix + RefreshSeries)
4.5 GOTCHAS LEARNED (would bite again) #
- Recyclarr
:latestimage does not exist on GHCR — pin:8 - Recyclarr v8 removed official include templates — write custom profile YAML,
includefiles must live inincludes/subdir - Unpackerr env vars need
_0_index - Bazarr settings API silently no-ops on radarr/sonarr keys — edit YAML directly
- Lidarr root folder requires
defaultMetadataProfileId≥1 - Prowlarr indexer create requires
appProfileId:1(error message is cryptic) - Plex:
language=en-USinvalid →en; empty-dir Music library creation fails docker compose up -dfrom agent context: run via background wrapper +notify=true(foreground gets killed by server-watch heuristic)- rars/unpacked media: Person of Interest is an unextracted RAR set — Plex can't see it; decide unpack vs delete
- OrbStack PATH:
~/.orbstack/binmay not be on PATH in agent shells
4.6 KNOWN GAPS / FOLLOW-UPS (final state 2026-09-11 — see ~/arrstack/HANDOFF.md for live handoff doc) #
Resolved 2026-09-11 (second pass):
Still open (see ~/arrstack/HANDOFF.md §4 for full detail):
Resolved since v1.2:
Red-team findings (2026-09-11) — answered:
- Mullvad client on macOS: NOT required. Gluetun owns the WireGuard tunnel inside Docker; qBittorrent shares its netns and has no route without it (kill-switch verified:
docker compose stop gluetun→ qbit DNS fails; start → recovers on Mullvad IP 138.199.6.212). Host traffic (incl. Tailscale) is independent — Tailscale is a separate tunnel and unaffected. - Credentials in Bitwarden: YES for all UIs. Sonarr/Prowlarr (owner-created), Radarr/Lidarr/Bazarr/Seerr-local/qBittorrent (agent-generated, owner copied into Bitwarden). Keychain duplicates deleted. Remaining secrets NOT in Bitwarden:
~/arrstack/.envMullvad private key (file, chmod 600), per-app API keys in each config.xml (standard arr practice), Jellyfin API key (in Jellyfin DB). Risk accepted: config-only, all behind auth. - Internet-exposed surface: ONLY Seerr via Tailscale Funnel (enforces auth: cookie required, API returns 401; Plex-auth + local admin). All other UIs bind LAN/orb.local only and require login (302/401/403 unauth). Two AUTHLESS services remain LAN-visible: Tdarr :8265 (no built-in auth) and Dozzle :8081 (localhost-only). macOS firewall is OFF — recommend enabling Tdarr auth via reverse proxy in P5 or relying on LAN trust.
Still open:
Indexer changes (2026-09-11):
- thepiratebay: baseUrl →
https://thepiratebay10.org(apibay.org CF-blocks home ISP IP; works via Mullvad egress) - limetorrents: REMOVED (all mirrors CF-blocked)
- Prowlarr moved into gluetun netns (
network_mode: service:gluetun; UI port published via gluetunports: ["8080:8080","9696:9696"]) so indexer egress = Mullvad; apps reach it athttp://host.docker.internal:9696(baseUrl in each synced indexer def updated fromprowlarrtohost.docker.internal) - Known limitation discovered: TPB releases arrive as MAGNETS (infohash only). Dead swarms stall qbit on "downloading metadata" indefinitely — watch queue and remove stalled items; DHT bootstrap verified working through gluetun, but sparse old swarms may never resolve
4.7 REPRODUCTION PROMPT (paste into agent on a new macOS device) #
I want you to set up a complete media server stack on this macOS device,
following the documented setup in ~/dev/_shared/docs/plans/PLAN-MediaServer.md
(§ Phase 4 documents everything). Confirm prerequisites first:
PREREQ CHECKS (do before anything):
- macOS 13+ (Apple Silicon preferred), Homebrew installed
- External media volume mounted, containing Media/{Movies,TV,Music,Downloads}
(create Downloads/{movies,tv,music} and Music if missing)
- Internal SSD free space ≥ 150 GB (branch: incomplete/tdarr-cache on internal SSD)
- Photos library may co-exist on the media volume: NEVER scan or write outside
Media/{Movies,TV,Music,Downloads}
ASK ME (do not guess):
1. My Plex account will auto-claim on first Plex launch — confirm username.
2. Mullvad WireGuard config zip path (generate at mullvad.net → WireGuard
config; I'll paste PrivateKey + Address, or give you the zip).
3. Which public domain/hostname for Seerr — default is Tailscale Funnel
(https://<machine>.<tailnet>.ts.net), NO DNS changes.
4. Any existing torrents/indexers to migrate (or start fresh).
EXECUTION ORDER (from the plan + Phase 4 recipe):
1. Install OrbStack (brew cask), open it once, verify docker works
(PATH may need ~/.orbstack/bin).
2. Create ~/arrstack/ layout: docker-compose.yml (13 services per plan §7.1
with Phase-4 corrections: recyclarr image :8, unpackerr env vars indexed
UN_<APP>_0_URL/API_KEY/PATHS), .env (chmod 600), config dirs.
3. docker compose up -d (background it; verify with docker compose ps).
4. Configure qBittorrent via WebUI API: categories movies/tv, save/temp paths,
strong password → store in Keychain.
5. Configure Radarr/Sonarr/Lidarr via API: root folders (/data/Movies etc.,
Lidarr needs defaultMetadataProfileId:1), download client = qbit at
gluetun:8080, rename+move on.
6. Add public indexers in Prowlarr (appProfileId:1), full-sync to apps.
7. Bazarr: edit config/config/config.yaml directly (settings API no-ops),
connect to radarr/sonarr service names.
8. Recyclarr v8: custom WEB-1080p profile in config/recyclarr/includes/,
docker compose run --rm recyclarr sync.
9. Unpackerr: indexed env vars for sonarr/radarr/lidarr + paths.
10. Retire native Jackett if present (brew services stop, brew uninstall --zap).
11. Tdarr server container + native macOS Tdarr node (VideoToolbox) — Phase 3.
12. Log rotation (~/.orbstack/config/docker.json), weekly backup + brew-upgrade
launchd jobs (scripts + plists in ~/arrstack/scripts/).
13. Plex Media Server: open once (auto-claims), add libraries via API
(language=en, agents: imdb for movies / thetvdb for series, correct
scanner pairing), trigger scans, verify counts.
14. Tailscale: install (cask needs sudo — prefer direct zip from
pkgs.tailscale.com), I'll log in via menu bar, then enable Funnel →
route to Seerr :5055.
15. Seerr: Plex-auth wizard + TOTP 2FA (I do this in browser).
16. Run acceptance tests T0-T11 from plan §16, log results.
RULES (non-negotiable, from plan):
- Plex + Jellyfin stay NATIVE (VideoToolbox). Everything else in Docker.
- Torrent traffic ONLY via gluetun (Mullvad WireGuard) — kill switch verified
by stopping gluetun and testing.
- No data deletion without my explicit approval; quarantine instead.
- Bulk media reorganization (ManualImport move-mode) requires my approval first.
- Every gate in plan §9 needs my explicit go.
4.8 CURRENT STACK STATE (snapshot at Phase 4 creation) #
- Containers: 13 up (gluetun healthy; qbittorrent, prowlarr, radarr, sonarr, lidarr, bazarr, seerr, watchtower healthy, dozzle, recyclarr, unpackerr, tdarr)
- Plex: 84 movies + 22 shows matched; native server v1.43.3
- Radarr: 81/84 movies with files (3 no-video: Good Grief, Heart of Stone, Superman)
- Sonarr: 23 series, 506/506 episodes matched
- qBittorrent: Ted Lasso + Mr. Robot both 100% seeded; old qbt.app creds in owner Bitwarden
- Tailscale: installed, awaiting owner login
- Backups: 1 generation at ~/Backups/arrstack/