This repository has no description
_shared docs plans PLAN-MediaServer.md
44 kB
Markdown
at main

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 #

  1. Remote streaming (Plex primary, Jellyfin secondary) + friend/family requests without open router ports.
  2. All torrent traffic (download and seed) exits via Mullvad WireGuard, with a true kill switch.
  3. Automated rename/organize of Movies & TV so both Plex and Jellyfin parse cleanly.
  4. Minimal storage growth: 1080p caps, HEVC preference, Tdarr compression of existing library.
  5. 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 (existing Movies, TV dirs — 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 with stat -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} / file Movie 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 #

  1. df -h / → confirm internal free space (branch §5).
  2. Install OrbStack: brew install --cask orbstack; enable start-at-login; verify docker compose version.
  3. Create folder layout (§5): Downloads, incomplete dirs, Music dir.
  4. cd ~/arrstack && docker compose up -d.
  5. qBittorrent: add categories movies,tv with save paths /data/Downloads/movies, /data/Downloads/tv; incomplete → /internal/{movies,tv}; on-completion move enabled; WebUI strong password + localhost binding.
  6. Prowlarr: re-add the indexers captured from old Jackett config; Settings→Apps→ Radarr + Sonarr (+Lidarr) full sync. Then brew uninstall --zap jackett.
  7. Radarr: root folder /data/Movies; download client qBittorrent at http://gluetun:8080 category movies; rename on; hardlinks per §5.
  8. Sonarr: root folder /data/TV; category tv; same settings.
  9. Lidarr: root folder /data/Music; quality = lossy 320.
  10. Bazarr: providers OpenSubtitles.com (API key) + Podnapisi; connect to radarr/sonarr by service name.
  11. Seerr: auth = Plex; import Plex users; connect Radarr/Sonarr/Lidarr by service name (http://radarr:7878 etc.); approvals ON for non-admins; enable 2FA (TOTP) for owner.
  12. Plex/Jellyfin: Plex "Update library periodically" 15 min; Jellyfin realtime monitoring on.
  13. 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|Downloads from 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):

  1. Owner confirms WHICH Porkbun domain → requests.<domain> hostname. ← TODO, blocks Phase 2
  2. Inventory ALL existing DNS records in Porkbun (screenshot/export; check for mail/MX, SPF, DKIM, existing sites).
  3. If live records exist and can't be recreated → STOP, escalate to owner (Option B fallback below).
  4. Recreate every record in Cloudflare (verify: dig @<cf-nameserver> <record> before cutover).
  5. Get the 2 assigned Cloudflare nameservers; change NS at Porkbun (Account → Domain Management → Nameservers → "Use custom nameservers (enter NS)").
  6. 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/.env only. 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>):

  1. Register a cheap domain (~$3–10/yr) directly in Cloudflare (registrar = Cloudflare; psingletary.com at Porkbun stays untouched).
  2. brew install cloudflared → Zero-Trust dashboard → tunnel media-stack → cloudflared service install <token>.
  3. Route rr.<domain> → http://localhost:5055 (Seerr), behind Cloudflare Access email-OTP (plan §15).
  4. Disable Funnel (tailscale funnel --https=443 off) or keep both (Funnel as fallback URL).
  5. 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) #

  1. 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) — or tailscale/tsidp for OIDC into tailnet apps. Treat as experimental; do NOT make it the only auth path. Keep Cloudflare Access on public URLs regardless.
  2. 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.
  3. Optional: Plex Pass trial to evaluate port-forward quality vs relay.

20. REFERENCES #


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)

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>.

  1. qBittorrent (via WebUI API, cookie auth /api/v2/auth/login):
    • Categories movies→/data/Downloads/movies, tv→/data/Downloads/tv, both download_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
  2. Prowlarr (/api/v1/): add indexer defs (definitionName from /api/v1/indexer/schema, must set appProfileId: 1), then applications (Radarr/Sonarr/Lidarr, syncLevel=fullSync, PURLs use service names) → indexers auto-push to apps
  3. Radarr/Sonarr/Lidarr (/api/v3/ or /api/v1/ for Lidarr): root folders first (defaultMetadataProfileId REQUIRED for Lidarr), download client (host gluetun, port 8080), naming config (rename on + format strings)
  4. Bazarr: settings-API POST is silently ignored for radarr/sonarr sections → stop container, edit config/config/config.yaml directly (set ip=radarr/sonarr service names + apikeys, use_radarr/use_sonarr: true), restart; verify via /api/system/status → radarr_version non-empty
  5. Recyclarr v8: quality_definition.type + include files in config/recyclarr/includes/; profile syntax = upgrade: {allowed, until_quality, until_score} + qualities: list. docker compose run --rm recyclarr sync once, then @daily cron runs
  6. Unpackerr: env vars are INDEXED: UN_SONARR_0_URL, UN_SONARR_0_API_KEY, UN_SONARR_0_PATHS (plain UN_SONARR_URL is silently ignored)
  7. Plex libraries (http://127.0.0.1:32400, token from ~/Library/Application Support/Plex Media Server/.LocalAdminToken, header X-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
  8. 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 :latest image does not exist on GHCR — pin :8
  • Recyclarr v8 removed official include templates — write custom profile YAML, include files must live in includes/ 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-US invalid → en; empty-dir Music library creation fails
  • docker compose up -d from 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/bin may 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:

  1. 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.
  2. 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/.env Mullvad 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.
  3. 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 gluetun ports: ["8080:8080","9696:9696"]) so indexer egress = Mullvad; apps reach it at http://host.docker.internal:9696 (baseUrl in each synced indexer def updated from prowlarr to host.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/