# 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. (Cloudflare Tunnel) ─▶ Seerr :5055 Remote users ─▶ Plex (relay or port-forward, §11) ; optional media. ─▶ 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/`; gluetun secrets in `~/arrstack/.env` (chmod 600, never commit). ### 7.1 docker-compose.yml (Phase 1 core) ```yaml 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/ MEDIA_ROOT="/Volumes/photos library iCloud/Media" MULLVAD_WG_PRIVATE_KEY= MULLVAD_WG_ADDRESS=
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 0. `df -h /` → confirm internal free space (branch §5). 1. Install OrbStack: `brew install --cask orbstack`; enable start-at-login; verify `docker compose version`. 2. Create folder layout (§5): Downloads, incomplete dirs, Music dir. 3. `cd ~/arrstack && docker compose up -d`. 4. **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. 5. **Prowlarr:** re-add the indexers captured from old Jackett config; Settings→Apps→ Radarr + Sonarr (+Lidarr) full sync. Then `brew uninstall --zap jackett`. 6. **Radarr:** root folder `/data/Movies`; download client qBittorrent at `http://gluetun:8080` category `movies`; rename on; hardlinks per §5. 7. **Sonarr:** root folder `/data/TV`; category `tv`; same settings. 8. **Lidarr:** root folder `/data/Music`; quality = lossy 320. 9. **Bazarr:** providers OpenSubtitles.com (API key) + Podnapisi; connect to radarr/sonarr by service name. 10. **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. 11. **Plex/Jellyfin:** Plex "Update library periodically" 15 min; Jellyfin realtime monitoring on. 12. 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):** ```bash 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.` 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 @ ` 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 `; then create the Cloudflare Tunnel + hostnames + Access policies. **Fallback (Option B):** leave DNS at Porkbun entirely; publish Seerr via **Tailscale Funnel** (`https://..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 ` (runs at login/boot). Routes: `requests.` → `http://localhost:5055`; `media.` → `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.` 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 |restart |` `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.`): 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 `. 3. Route `rr.` → `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 - 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) **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// ← 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 /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 `/config/config.xml` ``. 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=` — **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):** - [x] Silo duplicate listing: empty "Silo 2023" dir tree removed; 10/10 S03 eps clean - [x] TV library audit: 6 nested release dirs verified legitimate (media present, Sonarr parses) - [x] Homarr deployed (v1.77.0, port 7575): admin user, 10 integrations (qbit/sonarr/radarr/lidarr/prowlarr/bazarr/jellyfin/plex/seerr/tdarr), MCP server live on /api/mcp/mcp (69 tools, key in Keychain `homarr-mcp`) - [x] Plex remote access: PublishServerOnPlexOnlineKey enabled, relay mapped, sections published to plex.tv - [x] OrbStack subnet bug fixed: compose network pinned to 192.168.199.0/24 (default got invalid host IP 192.168.97.0 breaking Plex GDM + plex.tv publish) - [x] Watchtower fork (nicholas-fedor) confirmed active+current; Diun evaluated and rejected (notify-only) — owner decision logged - [x] All 15 container images current (digest-verified); Dozzle updated via forced watchtower run - [x] Seerr 4K triple-enforced: flags off, no 4K server, 2160p quality defs capped to 1 Mb/min - [x] Quarantine: 353 subtitle/extra files merged into library; 24 dirs remain (srt for not-yet-downloaded movies) **Still open (see ~/arrstack/HANDOFF.md §4 for full detail):** - [ ] IPTorrents fallback setup — needs owner creds; seed 20160min rule for IPT category - [ ] Tdarr library/flows (Option A: 14-day file-age gate) + native macOS node for VideoToolbox - [ ] Mrs. Davis S01E08 re-grab (blocked: TPB magnet dead swarm; needs IPT or FlareSolverr-based indexer) - [ ] Plex web UI shows empty libraries for owner (server-side verified correct; SPA cache suspected) - [x] Mr. Robot: RESOLVED — relocated to `/data/TV/Mr. Robot/`, recheck 100%, seeding - [ ] macOS firewall OFF; Tdarr :8265 unauthenticated on LAN (accept or P5) **Resolved since v1.2:** - [x] Person of Interest RAR set: UNPACKED — all 103 episodes extracted via 7zz, Sonarr matched 103/103 - [x] Mrs. Davis S01E08 corrupt file: quarantined to `Movie-subtitles-and-extras/corrupt-files/` - [x] Mullvad desktop app: verified NOT needed for torrent protection (gluetun is self-contained kill switch, T3 re-verified twice); app left installed but Disconnected - [x] Keychain duplicates removed — all UI credentials now single-sourced in Bitwarden - [x] Stray Tailscale TLS private key removed from git repo working tree (regenerated under ~/arrstack/, chmod 600) **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:** - [ ] Mrs. Davis S01E08 re-grab BLOCKED: TPB magnets stall on metadata (dead swarm); needs FlareSolverr (P5) or a .torrent-serving TV indexer. Corrupt original quarantined; Sonarr episode unmonitored→re-monitor when a source exists - [x] Mr. Robot torrent: RESOLVED — relocated to /data/TV/Mr. Robot/, recheck 100%, seeding - [ ] Movie-subtitles-and-extras/ quarantine — owner review, then delete or merge srt files - [ ] FlareSolverr if eztv/1337x wanted (P5) — also unblocks Mrs Davis S01E08 re-grab - [ ] Native qbittorrent.app + BT_backup cleanup after container qbit fully takes over - [ ] Tdarr :8265 has no auth on LAN; Dozzle localhost-only — acceptable or proxy with auth (P5) **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://..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__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/