# Jellyfin Migration Implementation Plan > **For agentic workers:** This plan is executed in **Cursor**, not Claude Code. It deliberately > does **not** use this repo's Claude Code skills (`homelab-apply`, `homelab-ssh`) or MCP servers > (portainer, uptime-kuma, authentik). Every action is a literal shell command, file edit, or > named UI step. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace host-native Plex on valhalla with containerised Jellyfin, preserving curated artwork, collections and per-user watch history, then decommission Plex. **Architecture:** A new Portainer-managed stack `stacks/streaming/` (jellyfin, jellystat + postgres, wizarr, jellyplex-watched) on the shared `edge` network behind Caddy. Bulk data (metadata, trickplay, cache, transcodes) binds to `/storage1`; only SQLite config lands on the 97%-full root disk. Plex runs untouched until Task 23. **Tech Stack:** Docker Compose, Portainer (Gitea git-polling), Caddy, Jellyfin 10.11, PostgreSQL 16, systemd timers, Uptime Kuma push monitors. **Spec:** `docs/superpowers/specs/2026-08-05-jellyfin-migration-design.md` --- ## A note on "tests" in this plan This is deployment configuration, not application code, so there is no test suite to write first. The TDD discipline is preserved in a different form: **every task states its verification command and expected output before the change is made**, and no task is complete until that command produces that output. Treat a failed verification exactly as you would a failing test — stop and diagnose, do not proceed. ## Conventions this repo enforces Violating any of these breaks a live system. They are not stylistic. 1. **The repo is canonical.** Editing files here changes nothing until pushed to Gitea. Portainer polls `https://gitea.ginnoir.com/ginnoir/homelabstack` on `main` every 5 minutes. 2. **No `${VAR}` interpolation in compose, ever.** Every service gets `env_file: stack.env`, and variable names in `stack.env` are exactly what the container reads. 3. **Portainer UI environment variables must be empty** for application stacks. All config comes from `stack.env`. 4. **LF line endings only.** `.gitattributes` enforces this. CRLF in `stack.env` appends stray `\r` to values and breaks containers silently. 5. **`stack.env` is committed intentionally.** Secrets are versioned in this private repo. Do not gitignore or scrub them. 6. **Infra images are version-pinned** with `com.centurylabs.watchtower.enable=false`. Apps run `:latest` under Watchtower. ## Host facts you will need | Fact | Value | |---|---| | SSH | `ssh -o BatchMode=yes ginnoir@valhalla` | | valhalla LAN IP | `192.168.1.69` | | Plex | host-native systemd unit `plexmediaserver`, data at `/var/lib/plex` → `/storage1/labdata/plex` | | Plex API | `http://192.168.1.69:32400` | | Root disk | `/dev/sda2`, 194G, **97% full at plan time** | | Libraries | `/storage1/Movies`, `/storage1/TV`, `/storage1/Anime` | | Free ports | 8096, 5690, 3009 all confirmed unused | --- # PHASE 0 — Disk remediation **Nothing in Phase 1 may start until Task 4 is complete.** The root disk is at 97% and `/config` lives on it. This phase is a hard gate. --- ### Task 1: Reclaim Docker disk space **Files:** none — live host operation. - [ ] **Step 1: Record the baseline** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h / | tail -1; docker system df" ``` Write the output into the task notes below. Expected at plan time: `/dev/sda2 194G 179G 5.7G 97%` and roughly `Images 124 / 123.5GB / 51.47GB reclaimable`. - [ ] **Step 2: Verify no container depends on what you are about to remove** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker ps -a --filter status=exited --format '{{.Names}}\t{{.Image}}'" ``` Expected: a short list, or empty. Any **exited container you still want** keeps its image, so note them. `docker image prune -a` removes only images not referenced by *any* container, including stopped ones — it will not break a stopped-but-wanted service. - [ ] **Step 3: Prune** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker image prune -af && docker builder prune -f" ``` - [ ] **Step 4: Verify space was reclaimed** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h / | tail -1" ``` Expected: `Avail` is now **at least 40G** and `Use%` is at or below **80%**. If it is not, stop. Do not proceed to Task 2 — investigate what is holding the space. - [ ] **Step 5: Confirm all containers are still running** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker ps --format '{{.Names}}\t{{.Status}}' | grep -c Up" ``` Expected: **71 or more** (the count before the prune). Watchtower re-pulls anything it needs. --- ### Task 2: Audit what else is consuming the root disk **Files:** none — live host operation. - [ ] **Step 1: Find the top consumers outside Docker** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo du -xh --max-depth=2 / 2>/dev/null | sort -h | tail -25" ``` `-x` keeps it on `/dev/sda2` and stops it wandering into the 94T of `/storage1`. - [ ] **Step 2: Check the Docker containerd image store specifically** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo du -sh /var/lib/docker /var/lib/containerd /var/log 2>/dev/null" ``` This is the directory that caused the 2026-07-07 outage (all Postgres instances crash-looped on "No space left on device"). It should now be well under 60G. - [ ] **Step 3: Record findings** Append the output to this task as a comment. You are looking for anything **structurally growing** — not one-off large files. If you find a second unbounded growth source, add a task to this plan to bound it before continuing. - [ ] **Step 4: Commit the findings** ```bash git add docs/superpowers/plans/2026-08-05-jellyfin-migration.md git commit -m "docs(plan): record root disk audit findings" ``` --- ### Task 3: Add root-disk free-space alerting Uptime Kuma cannot inspect disk space directly. The pattern is a **push monitor**: valhalla reports in on a timer, and reports `down` when free space is low. If the timer stops, Kuma also alerts — so this catches both "disk full" and "monitoring broke". **Files:** - Create on host: `/usr/local/bin/root-disk-check.sh` - Create on host: `/etc/systemd/system/root-disk-check.service` - Create on host: `/etc/systemd/system/root-disk-check.timer` > These are host-managed files, not repo files. valhalla has **no crontab** — systemd timers only > (this matches the existing `docker-prune.timer`). - [ ] **Step 1: Create the Uptime Kuma push monitor and capture its token** In the Uptime Kuma UI at `https://uptime.ginnoir.com`: 1. **Add New Monitor** 2. Monitor Type: **Push** 3. Friendly Name: `valhalla root disk` 4. Heartbeat Interval: `300` seconds 5. Retries: `1` 6. Notifications: tick the existing **ntfy** notification 7. Save The monitor page then shows a Push URL of the form `https://uptime.ginnoir.com/api/push/XXXXXXXXXX`. **Copy the token** (the `XXXXXXXXXX` part). Record it here before continuing — the next step needs it: ``` PUSH_TOKEN = ____________________ ``` - [ ] **Step 2: Write the check script on the host** Substitute your real token for `PUSH_TOKEN_HERE`. ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo tee /usr/local/bin/root-disk-check.sh >/dev/null <<'EOF' #!/bin/sh # Reports root-disk headroom to Uptime Kuma. Alerts below MIN_FREE_PCT. set -eu MIN_FREE_PCT=15 TOKEN=PUSH_TOKEN_HERE USED_PCT=\$(df --output=pcent / | tail -1 | tr -dc '0-9') FREE_PCT=\$((100 - USED_PCT)) AVAIL=\$(df -h --output=avail / | tail -1 | tr -d ' ') if [ \"\$FREE_PCT\" -ge \"\$MIN_FREE_PCT\" ]; then STATUS=up else STATUS=down fi curl -fsS --max-time 10 \\ \"https://uptime.ginnoir.com/api/push/\${TOKEN}?status=\${STATUS}&msg=root%20\${AVAIL}%20free%20(\${FREE_PCT}%25)\" \\ >/dev/null EOF sudo chmod 755 /usr/local/bin/root-disk-check.sh" ``` - [ ] **Step 3: Run it once by hand and verify Kuma turns green** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo /usr/local/bin/root-disk-check.sh && echo OK" ``` Expected: `OK`, and the `valhalla root disk` monitor in Kuma flips to **Up** within a few seconds with a message like `root 45G free (23%)`. If it prints a curl error instead, the token is wrong — fix it before continuing. - [ ] **Step 4: Create the systemd service and timer** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo tee /etc/systemd/system/root-disk-check.service >/dev/null <<'EOF' [Unit] Description=Report root disk headroom to Uptime Kuma [Service] Type=oneshot ExecStart=/usr/local/bin/root-disk-check.sh EOF sudo tee /etc/systemd/system/root-disk-check.timer >/dev/null <<'EOF' [Unit] Description=Run root-disk-check every 5 minutes [Timer] OnBootSec=2min OnUnitActiveSec=5min AccuracySec=30s [Install] WantedBy=timers.target EOF sudo systemctl daemon-reload && sudo systemctl enable --now root-disk-check.timer" ``` - [ ] **Step 5: Verify the timer is scheduled** ```bash ssh -o BatchMode=yes ginnoir@valhalla "systemctl list-timers root-disk-check.timer --no-pager" ``` Expected: one row showing `root-disk-check.timer` with a `NEXT` time under 5 minutes away. - [ ] **Step 6: Verify the alert path actually fires** Temporarily raise the threshold so the script reports `down`: ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo sed -i 's/^MIN_FREE_PCT=15/MIN_FREE_PCT=99/' /usr/local/bin/root-disk-check.sh && sudo /usr/local/bin/root-disk-check.sh" ``` Expected: the Kuma monitor flips to **Down** and an ntfy notification arrives. Then restore it: ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo sed -i 's/^MIN_FREE_PCT=99/MIN_FREE_PCT=15/' /usr/local/bin/root-disk-check.sh && sudo /usr/local/bin/root-disk-check.sh" ``` Expected: monitor returns to **Up**. An untested alert is not an alert. --- ### Task 4: Correct the Plex documentation in CLAUDE.md `CLAUDE.md` documents a Plex data path that does not exist. Anyone (human or agent) following it will operate on the wrong directory. **Files:** - Modify: `CLAUDE.md` (the `### Plex Media Server` section) - [ ] **Step 1: Verify the true paths one more time** ```bash ssh -o BatchMode=yes ginnoir@valhalla "ls -ld /var/lib/plex; readlink -f /var/lib/plex; ls -d /var/lib/plexmediaserver 2>&1" ``` Expected: `/var/lib/plex -> /storage1/labdata/plex`, and `ls: cannot access '/var/lib/plexmediaserver': No such file or directory`. - [ ] **Step 2: Replace the incorrect line** In `CLAUDE.md`, find: ```markdown - **App data / metadata / DB:** `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/` ``` Replace it with: ```markdown - **App data / metadata / DB:** `/var/lib/plex/Plex Media Server/` — note `/var/lib/plex` is a **symlink to `/storage1/labdata/plex`**, so Plex's 383G of app data lives on the ZFS array, not the root disk. (`/var/lib/plexmediaserver/` does not exist.) ``` - [ ] **Step 3: Also correct the media library paths in the same section** Find: ```markdown - **Media libraries:** `/storage1/Media/{Movies,TV,Anime}` — same ZFS pool bind-mounted into the *arr containers. ``` Replace with: ```markdown - **Media libraries:** `/storage1/{Movies,TV,Anime}` — `/storage1/Media/*` are symlinks to these. Same ZFS pool bind-mounted into the *arr containers. ``` - [ ] **Step 4: Verify no other stale Plex paths remain** ```bash grep -rn "plexmediaserver/Library" . --include="*.md" ``` Expected: **no output**. If the string appears in `.claude/skills/` or `.agents/skills/`, fix those too. - [ ] **Step 5: Commit** ```bash git add CLAUDE.md git commit -m "docs: correct Plex data and library paths in CLAUDE.md /var/lib/plexmediaserver does not exist; the real path is /var/lib/plex, itself a symlink to /storage1/labdata/plex. Media libraries are /storage1/{Movies,TV,Anime} with /storage1/Media/* as symlinks to them." ``` **PHASE 0 GATE:** Do not continue until `df -h /` shows at least 40G available and the `valhalla root disk` Kuma monitor is green. --- # PHASE 1 — Deploy the streaming stack --- ### Task 5: Create the host directory tree Directories must exist and be owned by UID/GID 1000 **before** the containers start, or Jellyfin will fail to write and the bind mounts will be created root-owned by Docker. **Files:** none — live host operation. - [ ] **Step 1: Create the directories** ```bash ssh -o BatchMode=yes ginnoir@valhalla "mkdir -p /storage1/labdata/jellyfin/{metadata,cache,trickplay,transcodes} && sudo mkdir -p /config/{jellyfin,jellystat,jellystat-db,wizarr} && sudo chown -R 1000:1000 /config/jellyfin /config/jellystat /config/wizarr /storage1/labdata/jellyfin && echo CREATED" ``` `/config/jellystat-db` is deliberately **not** chowned — the postgres image manages its own ownership as UID 999. - [ ] **Step 2: Verify ownership and placement** ```bash ssh -o BatchMode=yes ginnoir@valhalla "ls -ld /config/jellyfin /config/jellystat /config/wizarr /storage1/labdata/jellyfin/*; df -h /config/jellyfin /storage1/labdata/jellyfin | tail -3" ``` Expected: `/config/*` owned by `1000 1000` and on `/dev/sda2`; `/storage1/labdata/jellyfin/*` owned by `1000 1000` and on `storage1` (virtiofs). **If the `/storage1` paths report `/dev/sda2`, stop — `/storage1` is not mounted** and you would be writing into the placeholder directory that caused the 2026-07-06 incident. --- ### Task 6: Write the streaming stack compose file **Files:** - Create: `stacks/streaming/docker-compose.yml` - [ ] **Step 1: Generate the secrets you will need in Task 7** ```bash echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" echo "JWT_SECRET=$(openssl rand -hex 32)" ``` Keep this output — Task 7 pastes it into `stack.env`. - [ ] **Step 2: Create `stacks/streaming/docker-compose.yml`** ```yaml # streaming stack — Jellyfin and its satellites. # # Storage split is deliberate: SQLite config on the root SSD, all bulk data # (metadata, trickplay, cache, transcodes) on /storage1. The root disk is only # 194G and has filled twice; nothing here may grow unbounded on it. # # Trickplay gets an explicit sub-bind because Jellyfin 10.11 moved it to # /config/data/trickplay and it is NOT relocatable from the UI. # # Transcodes are disk-backed, not tmpfs: jellyfin#16608 means Remux and # DirectStream jobs finish before the segment cleaner's first check, orphaning # segments. Plex already writes transcodes to virtiofs at a 48% transcode rate. # # Env convention: every service gets stack.env via env_file; var names there are # exactly what the containers read. No ${VAR} interpolation, no Portainer UI # env vars. services: jellyfin: container_name: jellyfin image: lscr.io/linuxserver/jellyfin:latest restart: unless-stopped networks: [streaming, edge] env_file: - stack.env environment: - JELLYFIN_PublishedServerUrl=https://jellyfin.ginnoir.com volumes: - /etc/localtime:/etc/localtime:ro - /config/jellyfin:/config - /storage1/labdata/jellyfin/metadata:/config/metadata - /storage1/labdata/jellyfin/cache:/config/cache - /storage1/labdata/jellyfin/trickplay:/config/data/trickplay - /storage1/labdata/jellyfin/transcodes:/transcode - /storage1/Movies:/data/movies:ro - /storage1/TV:/data/tv:ro - /storage1/Anime:/data/anime:ro ports: - "8096:8096" jellystat-db: container_name: jellystat-db image: postgres:16-alpine restart: unless-stopped networks: [streaming] env_file: - stack.env labels: - "com.centurylabs.watchtower.enable=false" volumes: - /config/jellystat-db:/var/lib/postgresql/data jellystat: container_name: jellystat image: cyfershepard/jellystat:latest restart: unless-stopped networks: [streaming, edge] depends_on: - jellystat-db env_file: - stack.env volumes: - /config/jellystat:/app/backend/backup-data ports: - "3009:3000" wizarr: container_name: wizarr image: ghcr.io/wizarrrr/wizarr:latest restart: unless-stopped networks: [streaming, edge] env_file: - stack.env volumes: - /config/wizarr:/data/database ports: - "5690:5690" jellyplex-watched: container_name: jellyplex-watched image: luigi311/jellyplex-watched:latest restart: unless-stopped networks: [streaming, edge] env_file: - stack.env networks: streaming: name: streaming driver: bridge edge: name: edge external: true ``` - [ ] **Step 3: Verify the YAML parses** ```bash docker compose -f stacks/streaming/docker-compose.yml config --quiet && echo "YAML OK" ``` Expected: `YAML OK`. A non-zero exit means a syntax error — fix before committing. > If `stack.env` does not exist yet this may warn about a missing env file. Complete Task 7 and > re-run this step. --- ### Task 7: Write the streaming stack environment file **Files:** - Create: `stacks/streaming/stack.env` - [ ] **Step 1: Get the Plex token** The token is in Plex's preferences on the host: ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo grep -o 'PlexOnlineToken=\"[^\"]*\"' '/var/lib/plex/Plex Media Server/Preferences.xml'" ``` Expected: `PlexOnlineToken="xxxxxxxxxxxxxxxxxxxx"`. Record the value. - [ ] **Step 2: Create `stacks/streaming/stack.env`** Substitute the two secrets generated in Task 6 Step 1 and the Plex token from Step 1. `JELLYFIN_TOKEN` is filled in later, in Task 16 — leave it as the empty placeholder for now. ```bash # streaming stack config — injected into every service via env_file. # Var names are exactly what the containers read; no ${VAR} interpolation, # no Portainer UI env vars needed. # --- shared --- TZ=America/Chicago PUID=1000 PGID=1000 # --- jellystat-db (postgres:16-alpine) --- POSTGRES_DB=jfstat POSTGRES_USER=jellystat POSTGRES_PASSWORD=REPLACE_WITH_GENERATED_POSTGRES_PASSWORD # --- jellystat --- POSTGRES_IP=jellystat-db POSTGRES_PORT=5432 JWT_SECRET=REPLACE_WITH_GENERATED_JWT_SECRET # --- jellyplex-watched --- # Plex is host-native, not containerised, so it is reached on the LAN IP. PLEX_URL=http://192.168.1.69:32400 PLEX_TOKEN=REPLACE_WITH_PLEX_TOKEN JELLYFIN_BASEURL=http://jellyfin:8096 JELLYFIN_TOKEN= SYNC_FROM_PLEX_TO_JELLYFIN=True SYNC_FROM_JELLYFIN_TO_PLEX=True # Start in dry-run. Task 16 flips this to False after the mapping is verified. DRYRUN=True SLEEP_DURATION=21600 LOG_LEVEL=INFO ``` - [ ] **Step 3: Verify LF line endings** CRLF in `stack.env` appends `\r` to every value and breaks containers in ways that are very hard to diagnose. ```bash file stacks/streaming/stack.env ``` Expected: `ASCII text` — **not** `ASCII text, with CRLF line terminators`. If it reports CRLF: ```bash git add --renormalize stacks/streaming/stack.env ``` - [ ] **Step 4: Verify the compose file now resolves cleanly** ```bash docker compose -f stacks/streaming/docker-compose.yml config --quiet && echo "COMPOSE OK" ``` Expected: `COMPOSE OK`. - [ ] **Step 5: Verify Portainer UI env compliance** ```bash pwsh -File scripts/check-portainer-stack-env.ps1 ``` Expected: no failures reported for `streaming`. This script exists to catch the exact mistake of leaving UI environment variables set. - [ ] **Step 6: Commit** ```bash git add stacks/streaming/docker-compose.yml stacks/streaming/stack.env git commit -m "feat(streaming): add jellyfin stack New Portainer stack: jellyfin, jellystat + postgres, wizarr and jellyplex-watched. Bulk data (metadata, trickplay, cache, transcodes) binds to /storage1; only SQLite config lands on the root disk. Trickplay gets an explicit sub-bind because 10.11 moved it to /config/data/trickplay, which is not relocatable from the UI. Transcodes are disk-backed rather than tmpfs because jellyfin#16608 orphans segments on the Remux/DirectStream jobs that are routine on this server." ``` --- ### Task 8: Add the Caddy site blocks **Files:** - Modify: `Caddyfile` `jellyfin` and `wizarr` are **public** — remote users and invite recipients reach them from anywhere, which is the entire point of leaving Plex. `jellystat` is internal-only and follows the existing tautulli pattern exactly. > Note the `internal_gate` vs `internal_only` distinction: inside a `route {}` block you **must** > use `internal_gate`, because Caddy sorts `route` ahead of `respond` and an `import internal_only` > above a catch-all route is dead code. This repo already had to fix that on nine sites. - [ ] **Step 1: Add the public streaming blocks** Insert after the `requests.ginnoir.com` block (around line 134): ```caddy # ============================================================= # STREAMING — public (replaces Plex's own remote access) # ============================================================= jellyfin.ginnoir.com { reverse_proxy jellyfin:8096 } wizarr.ginnoir.com { reverse_proxy wizarr:5690 } ``` - [ ] **Step 2: Add the internal-only stats block** Insert immediately after the `tautulli.ginnoir.com` block (around line 205): ```caddy jellystat.ginnoir.com { route { import internal_gate import authentik_outpost handle { import authentik_forward_auth reverse_proxy jellystat:3000 } } } ``` - [ ] **Step 3: Validate the Caddyfile before pushing** ```bash docker run --rm -v "${PWD}/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:latest caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile ``` Expected: `Valid configuration`. > This will warn about the `cloudflare` DNS module being unknown — that module is baked into the > custom Caddy image on valhalla, not the stock one. Warnings about `acme_dns cloudflare` are > expected and safe. Any **structural** error is not. - [ ] **Step 4: Commit** ```bash git add Caddyfile git commit -m "feat(caddy): add jellyfin, wizarr and jellystat sites jellyfin and wizarr are public by design — they replace Plex's own remote access. jellystat is internal_gate + forward_auth, matching the tautulli pattern." ``` --- ### Task 9: Register the stack in Portainer and deploy New stacks must be registered in Portainer once, by hand. After that, git-polling handles every subsequent change. **Files:** none — push and UI operation. - [ ] **Step 1: Push everything so far** ```bash git push origin main ``` This makes the stack files visible to Portainer. **Caddy reloads on this push too** — the Gitea Actions runner copies the Caddyfile and runs `caddy reload`. The three new sites will 502 until the containers exist, which is expected and harmless. - [ ] **Step 2: Register the stack** In the Portainer UI at `https://portainer.ginnoir.com`: 1. **Stacks → Add stack** 2. Name: `streaming` 3. Build method: **Repository** 4. Repository URL: `https://gitea.ginnoir.com/ginnoir/homelabstack` 5. Repository reference: `refs/heads/main` 6. Compose path: `stacks/streaming/docker-compose.yml` 7. **Authentication: on.** Use the fine-grained Gitea PAT that the other stacks use — the runner PAT cannot clone. 8. **GitOps updates: on**, Mechanism **Polling**, Interval `5m` 9. **Environment variables: leave completely empty.** This is enforced by `scripts/check-portainer-stack-env.ps1`. 10. **Deploy the stack** > Known quirk: the create call may appear to time out while actually succeeding. If it does, do > **not** retry — refresh the stack list and confirm `streaming` is present before doing anything > else, or you will create a duplicate. - [ ] **Step 3: Verify all five containers are running** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker ps --filter label=com.docker.compose.project=streaming --format 'table {{.Names}}\t{{.Status}}'" ``` Expected: five rows — `jellyfin`, `jellystat`, `jellystat-db`, `wizarr`, `jellyplex-watched` — all `Up`. `jellyplex-watched` may show as restarting or exited: it has no Jellyfin token yet and is in dry-run. That is expected until Task 16. - [ ] **Step 4: Verify Jellyfin is serving** ```bash ssh -o BatchMode=yes ginnoir@valhalla "curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8096/health" ``` Expected: `200`. - [ ] **Step 5: Verify the bind mounts landed where intended** This is the single most important verification in Phase 1. If trickplay or transcodes end up on the root disk, the disk will fill again. ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker inspect jellyfin --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} ({{.Mode}}){{\"\n\"}}{{end}}'" ``` Expected, exactly these sources: - `/config/jellyfin -> /config` - `/storage1/labdata/jellyfin/metadata -> /config/metadata` - `/storage1/labdata/jellyfin/cache -> /config/cache` - `/storage1/labdata/jellyfin/trickplay -> /config/data/trickplay` - `/storage1/labdata/jellyfin/transcodes -> /transcode` - `/storage1/Movies -> /data/movies (ro)` - `/storage1/TV -> /data/tv (ro)` - `/storage1/Anime -> /data/anime (ro)` The three library mounts **must** show `ro`. - [ ] **Step 6: Verify the public site resolves over HTTPS** ```bash curl -s -o /dev/null -w '%{http_code}\n' https://jellyfin.ginnoir.com/health ``` Expected: `200`. If you get a TLS error, check that the certificate issued — DNS-01 renewals on this host have failed before when the split-horizon override shadowed the SOA record. The canary: ```bash dig +short SOA ginnoir.com @192.168.1.1 ``` Expected: a real SOA record, not empty. --- ### Task 10: Complete Jellyfin setup and add libraries **Files:** none — UI operation. - [ ] **Step 1: Run the setup wizard** Browse to `https://jellyfin.ginnoir.com`. On the wizard: 1. Preferred display language: English 2. Create the admin account. **Use a strong, unique password** — this server is publicly reachable and Plex's relay is no longer hiding it. 3. Skip adding libraries here; Step 2 does it with the right settings. 4. Set metadata language / country 5. **Allow remote connections: yes.** Leave automatic port mapping **off** — Caddy handles ingress. - [ ] **Step 2: Add the three libraries** Dashboard → Libraries → Add Media Library. Create each with these settings: | Setting | Movies | TV | Anime | |---|---|---|---| | Content type | Movies | Shows | Shows | | Display name | `Movies` | `TV` | `Anime` | | Folder | `/data/movies` | `/data/tv` | `/data/anime` | | Preferred metadata language | English | English | English | For **all three**, in the library settings: - **Enable "Save artwork into media folders": OFF**. The library mounts are read-only, and leaving this on generates a stream of write errors in the logs. - **"Enable trickplay image extraction": OFF for now.** Task 13 turns it on deliberately, after the thread cap is configured. Leaving it on here starts an uncapped multi-day job that will compete with the initial scan. For **Anime only**, additionally set metadata downloaders to prefer **AniDB** and **AniList** above TheMovieDb (these plugins are installed in Task 14 — revisit this after that task). - [ ] **Step 3: Trigger and watch the initial scan** Dashboard → Scheduled Tasks → **Scan Media Library** → Run. ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker logs --tail 40 jellyfin" ``` - [ ] **Step 4: Verify item counts against Plex** Once the scan completes (expect a few hours for ~1891 items), check Dashboard → Libraries. Expected, approximately: - Movies: **~1357** - TV: **~173 series** - Anime: **~361 series** A shortfall over ~2% means naming problems worth investigating before you build anything on top of this library. - [ ] **Step 5: Verify nothing significant landed on the root disk** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h / | tail -1; sudo du -sh /config/jellyfin; du -sh /storage1/labdata/jellyfin/*" ``` Expected: `/config/jellyfin` in the low **hundreds of MB to a few GB** (SQLite only), with `metadata` on `/storage1` holding the artwork. If `/config/jellyfin` is tens of GB, a bind mount is wrong — go back to Task 9 Step 5. - [ ] **Step 6: Regenerate the bookmark files** ```bash pwsh -File scripts/gen-bookmarks.ps1 git add bookmarks-domains.html bookmarks-ports.html git commit -m "chore: regenerate bookmarks for streaming stack" git push origin main ``` --- # PHASE 2 — Parity with Plex --- ### Task 11: Install Plexyfin and import curated artwork and collections Plexyfin is the only reason any of Plex's 383G is worth reading. It copies the curated **15G** `Metadata` investment — posters, backdrops and collections — directly from the live PMS API. **This plugin is destructive.** It deletes and replaces artwork wholesale on every run. It is installed, run once, and then removed. **Files:** none — UI operation against the live Plex API. - [ ] **Step 1: Confirm Plex is still running and reachable** ```bash ssh -o BatchMode=yes ginnoir@valhalla "systemctl is-active plexmediaserver; curl -s -o /dev/null -w '%{http_code}\n' 'http://192.168.1.69:32400/identity'" ``` Expected: `active` and `200`. Everything in this task reads from the live Plex server. - [ ] **Step 2: Add the Plexyfin repository** Jellyfin Dashboard → Plugins → Repositories → **Add**: - Repository Name: `Plexyfin` - Repository URL: `https://raw.githubusercontent.com/cleverdevil/plexyfin/main/metadata/stable/manifest.json` - [ ] **Step 3: Install and restart** Dashboard → Plugins → Catalog → **Plexyfin** → Install. Then restart Jellyfin: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker restart jellyfin && sleep 20 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8096/health" ``` Expected: `200`. - [ ] **Step 4: Configure Plexyfin** Dashboard → Plugins → Plexyfin: - Plex Server URL: `http://192.168.1.69:32400` - Plex Token: the token recorded in Task 7 Step 1 - Enable **Sync Collections** - Enable **Sync Artwork** - Save - [ ] **Step 5: Run the sync** Use the plugin's **Sync Now** button, then watch: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker logs --tail 60 -f jellyfin" ``` Expected: progress lines naming collections and items. Let it finish completely — interrupting leaves artwork half-replaced. - [ ] **Step 6: Verify collections and artwork arrived** In the Jellyfin web UI: - Movies → Collections shows your Plex collections - Spot-check five movies where you know you set custom posters in Plex; confirm the poster matches Plex, not TMDB's default - [ ] **Step 7: Remove the plugin** This is not optional. Left installed, it will destroy any artwork you later customise in Jellyfin. Dashboard → Plugins → Plexyfin → **Uninstall**. Then Dashboard → Plugins → Repositories → remove the `Plexyfin` repository. Restart Jellyfin: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker restart jellyfin && sleep 20 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8096/health" ``` Expected: `200`. - [ ] **Step 8: Verify it is gone** Dashboard → Plugins → My Plugins. Expected: **Plexyfin is not listed.** --- ### Task 12: Configure transcoding — NVDEC decode, CPU encode, segment cleanup The P100 cannot encode — GP100 has no NVENC — so this does nothing for live streaming transcode. It exists for one reason: **trickplay generation in Task 13 is decode-plus-JPEG-encode**, and NVDEC accelerates exactly that. **Files:** - Modify: `stacks/streaming/docker-compose.yml` - [ ] **Step 1: Check whether llama-swap currently holds the GPU** ```bash ssh -o BatchMode=yes ginnoir@valhalla "nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv" ``` If a llama-swap process holds most of the 16G, either wait for it to idle or accept that trickplay generation will be slower. Note what you found. - [ ] **Step 2: Add the CDI device to the jellyfin service** In `stacks/streaming/docker-compose.yml`, inside the `jellyfin` service, add after `env_file`: ```yaml devices: - nvidia.com/gpu=0 ``` - [ ] **Step 3: Commit and push** ```bash git add stacks/streaming/docker-compose.yml git commit -m "feat(streaming): give jellyfin CDI access to the P100 Decode only — GP100 has no NVENC. This is for trickplay generation, which is decode plus JPEG encode." git push origin main ``` - [ ] **Step 4: Wait for the redeploy, then verify the GPU is visible in the container** Portainer polls every 5 minutes. Then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker exec jellyfin nvidia-smi -L" ``` Expected: `GPU 0: Tesla P100-PCIE-16GB (UUID: GPU-f23318db-...)`. **If this errors**, the CDI device name is wrong. Check the available names and correct the compose file: ```bash ssh -o BatchMode=yes ginnoir@valhalla "grep -E '^\s+name:' /etc/cdi/nvidia.yaml" ``` - [ ] **Step 5: Enable NVDEC in Jellyfin** Dashboard → Playback → Transcoding: - Hardware acceleration: **NVIDIA NVENC** - Enable hardware decoding for: **H264**, **HEVC**, **VP9** - **Leave "Enable hardware encoding" UNCHECKED.** The P100 cannot encode; ticking this makes every transcode fail. - Save - [ ] **Step 5a: Configure the CPU encoder and segment cleanup** On the same Dashboard → Playback → Transcoding page, since encoding is CPU-bound: - **Encoding preset: `veryfast`.** The default is slower and this host has no encode offload. - **Transcode thread count: `8`.** Per session. With a measured peak of 7 concurrent streams, this bounds the worst case at 56 of 52 threads — deliberately just over-subscribed rather than unbounded, which is what the default does. - **Enable throttling: ON.** Pauses ffmpeg once it runs far enough ahead of the player. - **Enable segment deletion: ON.** - **Segment keep seconds: `360`.** - Save Segment deletion matters more here than it does on most servers. Per [jellyfin#16608](https://github.com/jellyfin/jellyfin/issues/16608), the cleaner's first check is at 20 seconds and the throttler's at 5, but Remux and DirectStream jobs finish in 2-7 seconds — so on those, neither ever fires. DirectStream (copy video + transcode audio) was **8 of 25** sampled sessions on this server. This setting does not fix that bug; it bounds the damage on every *other* job type. The transcode directory is on `/storage1` precisely because this bug cannot be configured away. - [ ] **Step 5b: Verify orphaned segments are actually being cleaned** Play something that transcodes, let it run two minutes, stop it, wait five minutes, then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "du -sh /storage1/labdata/jellyfin/transcodes; ls /storage1/labdata/jellyfin/transcodes | wc -l" ``` Expected: small size and a low file count. If this directory grows monotonically over the following days, add a weekly systemd timer to prune files older than 24 hours — the same pattern as Task 3's timer. - [ ] **Step 6: Verify a decode actually uses the GPU** Play a HEVC file that requires transcoding, from the web client, then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv | grep -i ffmpeg || echo 'no ffmpeg on GPU'" ``` Expected: an `ffmpeg` process listed. If you see `no ffmpeg on GPU`, decoding fell back to CPU — check Dashboard → Playback logs for the reason. This is not fatal; it only makes Task 13 slower. --- ### Task 13: Generate trickplay Plex's 365G of BIF preview thumbnails is **format-incompatible** with Jellyfin's tiled JPEG trickplay. None of it is reusable. Budget **300-400G on `/storage1`** and a multi-day first pass over ~1891 items. **Files:** none — UI operation plus monitoring. - [ ] **Step 1: Confirm you have the disk headroom** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h /storage1 | tail -1" ``` Expected: **at least 1T available**. At plan time there was 17T, so this is a formality — but confirm, because this task writes hundreds of gigabytes. - [ ] **Step 2: Configure trickplay with a thread cap** Dashboard → Playback → Trickplay: - Enable trickplay image extraction **during library scan**: **ON** - **Thread count: `20`.** The host has 52 threads. Leaving this at the default (all cores) starves live transcoding, which is the thing users actually notice. - Interval: `10000` ms (the default) - Width resolutions: `320` (the default) - Enable hardware decoding: **ON** (this is what Task 12 was for) - Save - [ ] **Step 3: Enable trickplay on each library** Dashboard → Libraries. For each of Movies, TV and Anime, edit the library and tick **Enable trickplay image extraction**. (These were deliberately left off in Task 10 Step 2.) - [ ] **Step 4: Start generation** Dashboard → Scheduled Tasks → **Generate Trickplay Images** → Run. - [ ] **Step 5: Monitor progress and disk growth** Check periodically over the following days: ```bash ssh -o BatchMode=yes ginnoir@valhalla "du -sh /storage1/labdata/jellyfin/trickplay; df -h /storage1 | tail -1; uptime" ``` Expected: `trickplay` growing steadily toward the 300-400G range; `/storage1` avail falling correspondingly; load average high but the host responsive. **Abort condition:** if `/storage1` available space drops below **500G**, stop the scheduled task immediately and reassess — do not let this fill the array. - [ ] **Step 6: Verify trickplay works in a client** Once the task reports complete, open any movie in the web client and scrub the timeline. Expected: preview thumbnails appear above the scrubber. - [ ] **Step 7: Confirm nothing leaked onto the root disk** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h / | tail -1; sudo du -sh /config/jellyfin" ``` Expected: root disk essentially unchanged from Task 10. If it grew by tens of GB, the trickplay sub-bind is not working — stop and fix it. --- ### Task 14: Install the remaining plugins **Files:** none — UI operation. - [ ] **Step 1: Install Intro Skipper** The original plugin is unmaintained; use the maintained fork. **Verify 10.11 compatibility before installing** — this plugin patches the web player and a version mismatch breaks playback. 1. Check the fork's README for a stated 10.11 compatible release: `https://github.com/Bobby306/intro-skipper` 2. If, and only if, it declares 10.11 support, add its repository under Dashboard → Plugins → Repositories using the manifest URL from its README, then install. 3. If it does **not** declare 10.11 support, **skip this plugin** and note it here. It is optional polish, not a migration requirement. - [ ] **Step 2: Install the anime metadata plugins** Dashboard → Plugins → Catalog. Install from the official catalogue: - **AniDB** - **AniList** - [ ] **Step 3: Restart and verify** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker restart jellyfin && sleep 25 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8096/health" ``` Expected: `200`. - [ ] **Step 4: Point the Anime library at the anime providers** Dashboard → Libraries → Anime → edit. Under metadata downloaders, drag **AniDB** and **AniList** above **TheMovieDb**. Save, then run **Scan Media Library** limited to the Anime library. - [ ] **Step 5: Verify anime metadata improved** Spot-check five anime series. Expected: correct season/episode numbering and Japanese-release metadata rather than TMDB's Western groupings. > **Do not install:** `jellyfin-plugin-sso` (browser-only OIDC — Roku, Fire TV and tvOS cannot use > it, and those are the entire user base), Open Subtitles (Bazarr already writes subtitles to disk > and does it better), Playback Reporting (Jellystat supersedes it). --- ### Task 15: Connect Sonarr, Radarr and Bazarr to Jellyfin New imports must refresh Jellyfin the way they currently refresh Plex. **Leave the existing Plex connections in place** — both servers stay live until Task 22. **Files:** none — UI operation. - [ ] **Step 1: Create a Jellyfin API key** Jellyfin Dashboard → API Keys → **New API Key**, name it `arr-integration`. Copy the key. Record it here — Task 16 needs a Jellyfin key too, and you can reuse this one: ``` JELLYFIN_API_KEY = ____________________ ``` - [ ] **Step 2: Add the connection in Sonarr** At `https://sonarr.ginnoir.com` → Settings → Connect → **+** → **Emby / Jellyfin**: - Name: `Jellyfin` - Host: `jellyfin` - Port: `8096` - API Key: the key from Step 1 - Enable **On Import**, **On Upgrade**, **On Rename**, **On Series Delete** - Update Library: **on** - **Test**, then Save Expected: the Test button returns success. A failure here usually means the containers cannot see each other — both are on `edge`, so check that first. - [ ] **Step 3: Add the connection in Radarr** At `https://radarr.ginnoir.com` → Settings → Connect → **+** → **Emby / Jellyfin**. Same host, port and key. Enable **On Import**, **On Upgrade**, **On Rename**, **On Movie Delete**. Test and Save. - [ ] **Step 4: Add the connection in Bazarr** At `https://bazarr.ginnoir.com` → Settings → Subtitles → enable the Jellyfin/Emby integration with the same host, port and key, so subtitle downloads trigger a refresh. - [ ] **Step 5: Verify end to end** Trigger a rename or manual import in Radarr on a single film, then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker logs --tail 30 jellyfin | grep -i 'refresh\|library'" ``` Expected: a library refresh entry appearing within a minute of the Radarr action. --- # PHASE 3 — Watch-state sync --- ### Task 16: Bring up two-way watch history sync JellyPlex-Watched matches items by filename and provider IDs. It is good but not perfect, so this task runs it in dry-run first and only enables writes once the mapping is confirmed. **Files:** - Modify: `stacks/streaming/stack.env` - [ ] **Step 1: Fill in the Jellyfin token** In `stacks/streaming/stack.env`, set `JELLYFIN_TOKEN` to the API key recorded in Task 15 Step 1. Leave `DRYRUN=True` for now. - [ ] **Step 2: Commit, push and wait for the redeploy** ```bash git add stacks/streaming/stack.env git commit -m "feat(streaming): give jellyplex-watched its jellyfin token" git push origin main ``` Portainer redeploys within 5 minutes. - [ ] **Step 3: Run a dry-run and read the output carefully** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker restart jellyplex-watched && sleep 90 && docker logs --tail 120 jellyplex-watched" ``` Expected: it connects to both servers, lists users, and reports what it *would* sync. **Nothing is written in dry-run.** - [ ] **Step 4: Check for username mismatches** In the dry-run output, look for users present on one server but not the other. Plex usernames and Jellyfin usernames will not match for anyone who chose a different name at signup. If there are mismatches, add a mapping to `stacks/streaming/stack.env` using the tool's `USER_MAPPING` format — a JSON object of `plex_username: jellyfin_username`: ```bash USER_MAPPING={"plexname1": "jellyfinname1", "plexname2": "jellyfinname2"} ``` Commit, push, wait for redeploy, and re-run Step 3 until the mapping is clean. > Early on only your own account exists in Jellyfin, so most Plex users will legitimately have no > counterpart. That is expected — this mapping gets revisited as people are onboarded in Task 19. - [ ] **Step 5: Verify your own account's history would sync correctly** In the dry-run output, find your own user. Expected: a plausible list of watched items matching what you know you have watched in Plex. If it reports zero for a library you have definitely watched from, path matching is failing — diagnose before enabling writes. - [ ] **Step 6: Enable writes** In `stacks/streaming/stack.env`: ```bash DRYRUN=False ``` ```bash git add stacks/streaming/stack.env git commit -m "feat(streaming): enable jellyplex-watched writes after dry-run verification" git push origin main ``` - [ ] **Step 7: Verify a real sync round-trip** After the redeploy, mark an episode watched **in Jellyfin** that is unwatched in Plex. Then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker restart jellyplex-watched && sleep 120 && docker logs --tail 60 jellyplex-watched" ``` Check Plex: the episode should now be watched there too. Then do the reverse — mark something watched in Plex and confirm it appears in Jellyfin after the next cycle. Expected: both directions work. `SLEEP_DURATION=21600` means it runs every 6 hours from here on. **PHASE 3 GATE:** Jellyfin is now at parity with Plex, with two-way history sync. Everything after this point involves other people. --- # PHASE 4 — Pilot --- ### Task 17: Configure Wizarr Per the design decision, invites are issued **manually and selectively**. Most Plex users are dormant. There is no bulk invite generation and no scripted account creation in this plan. **Files:** none — UI operation. - [ ] **Step 1: Complete Wizarr setup** Browse to `https://wizarr.ginnoir.com` and create the admin account. - [ ] **Step 2: Connect it to Jellyfin** In Wizarr settings, add a media server: - Type: **Jellyfin** - URL: `http://jellyfin:8096` - API Key: the key from Task 15 Step 1 - [ ] **Step 3: Restrict which libraries invited users get** Grant **Movies**, **TV** and **Anime**. Nothing else exists, but set this explicitly so future libraries are not exposed by default. - [ ] **Step 4: Customise the onboarding wizard** Configure the post-invite steps to cover the three platforms that matter here — Roku, Fire TV / Android TV, and Apple TV / iOS — with the app store link for each. These account for the overwhelming majority of real playback. - [ ] **Step 5: Verify with a throwaway invite** Create an invite, open it in a private browser window, and complete the flow with a test account. Expected: the account appears in Jellyfin Dashboard → Users with only the three libraries granted. Delete the test account from both Wizarr and Jellyfin afterwards. --- ### Task 18: Pilot with real users **Files:** none — human process. - [ ] **Step 1: Pick 3-5 willing users covering every platform** You need **at least one each on Roku, Fire TV / Android TV, and Apple TV / iOS**. Platform coverage matters more than user count — a pilot of five Android users tells you nothing about Roku. - [ ] **Step 2: Send each an individual Wizarr invite** - [ ] **Step 3: Add their username mappings to jellyplex-watched** As each pilot user creates their Jellyfin account, add their Plex→Jellyfin mapping to `USER_MAPPING` in `stacks/streaming/stack.env` (see Task 16 Step 4), then commit and push. Their history then syncs both ways and they can move between servers freely. - [ ] **Step 4: Collect feedback on the three things that actually break** Ask each pilot user specifically about: playback start time, whether anything stutters mid-stream, and whether the onboarding wizard was followable. Vague "is it working?" gets vague answers. - [ ] **Step 5: Verify transcode behaviour under real load** ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker exec jellyfin ps aux | grep -c '[f]fmpeg'; uptime" ``` Run this while pilot users are actually watching. Expected: load average well under 52. - [ ] **Step 6: Record the pilot outcome** Note per platform whether playback worked, and any transcoding observations. This is the input to the Task 20 hardware decision. --- # PHASE 5 — Selective migration --- ### Task 19: Onboard users on demand **Files:** none — human process, ongoing. - [ ] **Step 1: Write a short explainer to accompany invites** One that answers, in plain language: why the change is happening, what to install on their device, that their watch history carries over, and that Plex keeps working until an announced date. **This is sent with an invite, not broadcast.** Save it somewhere you can paste from. - [ ] **Step 2: Issue invites individually as people ask or as you choose** Dormant accounts get nothing. There is no campaign and no deadline pressure at this stage. - [ ] **Step 3: Keep the user mapping current** Each time someone joins, add their Plex→Jellyfin mapping to `USER_MAPPING` in `stacks/streaming/stack.env`, then commit and push. Without this their history does not follow them. - [ ] **Step 4: Let it run at least 4 weeks** This is a **hard gate on elapsed time**, not on effort. Jellystat needs a meaningful sample before the Task 20 decision can be made on evidence rather than vibes. Both servers stay live throughout. --- ### Task 20: Decide the transcoding hardware question **Files:** none — analysis and a decision. - [ ] **Step 1: Set up Jellystat** Browse to `https://jellystat.ginnoir.com` (Authentik will challenge you first, and it is LAN or tailnet only). Create the admin account and connect it to Jellyfin at `http://jellyfin:8096` with the API key from Task 15. > Do this at the **start** of the 4-week window in Task 19, not the end, or it will have nothing > to report. - [ ] **Step 2: Pull the numbers after 4 weeks** From Jellystat, record: peak concurrent streams, the direct-play vs transcode ratio, and the most common transcode reasons. - [ ] **Step 3: Compare against the Plex baseline** The measured Plex baseline was: **48% transcode, peak 7 concurrent, 1080p SDR, no subtitle burn-in, no HDR tone-mapping**. Jellyfin's Android client direct-plays audio codecs Plex's refuses, and Android was the heaviest platform at 281 plays — so the transcode rate may well have **fallen**. - [ ] **Step 4: Check whether the CPU actually struggled** ```bash ssh -o BatchMode=yes ginnoir@valhalla "uptime; sudo journalctl -u docker --since '4 weeks ago' | grep -ci 'jellyfin.*oom' || echo 'no OOM events'" ``` Also ask users directly whether playback ever stuttered or failed to start. User reports beat graphs here. - [ ] **Step 5: Make and record the decision** - **If load is comfortable:** do nothing. Record that CPU-only transcoding was sufficient and close this task. - **If the CPU is straining:** buy an **Intel Arc A310 or A380** (~$100-150). No session limit, AV1, full encode plus decode. This requires a PCIe slot, an R730XD shutdown, and an IOMMU passthrough to the valhalla VM. **Check the new card's IOMMU group before buying** — only the P100's group 7 is known clean. Write that up as its own plan; it is not a step in this one. --- # PHASE 6 — Cutover --- ### Task 21: Convert Seerr from Plex to Jellyfin `seerr-team/seerr` supports Jellyfin natively, but converting an existing Plex-configured install is **unsupported upstream** ([seerr#2522](https://github.com/seerr-team/seerr/issues/2522), open). Hence the backup first and the documented fallback. **Files:** none — live operation. - [ ] **Step 1: Back up the Seerr configuration** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo tar czf /storage1/labdata/seerr-preJellyfin-$(date +%Y%m%d).tar.gz -C /config overseerr && ls -lh /storage1/labdata/seerr-preJellyfin-*.tar.gz" ``` Expected: a tarball listed with a non-trivial size. **Do not proceed without this.** - [ ] **Step 2: Attempt the in-place conversion** At `https://requests.ginnoir.com` → Settings → General, change the media server to **Jellyfin**: - Hostname: `jellyfin` - Port: `8096` - API key: the key from Task 15 Step 1 - Save, then Settings → Jellyfin → **Sync Libraries** and enable Movies, TV and Anime - [ ] **Step 3: Verify it works** Expected: libraries sync, existing media shows as **Available**, and a test request routes to Radarr or Sonarr correctly. - [ ] **Step 4: If the conversion failed, use the fallback** Stop Seerr, move the config aside, and start fresh: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker stop seerr && sudo mv /config/overseerr /config/overseerr.plex-era && sudo mkdir -p /config/overseerr && sudo chown 1000:1000 /config/overseerr && docker start seerr" ``` Then reconfigure from scratch at `https://requests.ginnoir.com`: Jellyfin as the media server, then re-add Radarr and Sonarr with their API keys. **Request history is lost** — this was an accepted trade-off in the design. - [ ] **Step 5: Verify user login** Have one migrated user sign in to Seerr with their Jellyfin credentials and submit a request. Expected: the request appears in Radarr or Sonarr. --- ### Task 22: Identify holdouts and cut the Plex integrations **Files:** none — analysis plus UI operations. - [ ] **Step 1: Find who is still on Plex and on what** ```bash ssh -o BatchMode=yes ginnoir@valhalla 'KEY=$(grep -E "^api_key" /config/tautulli/config.ini | head -1 | sed "s/.*= *//"); curl -s "http://localhost:8181/api/v2?apikey=$KEY&cmd=get_history&length=300" | python3 -c " import sys,json,collections d=json.load(sys.stdin)[\"response\"][\"data\"][\"data\"] c=collections.Counter((r.get(\"friendly_name\"),r.get(\"platform\")) for r in d) for k,v in c.most_common(30): print(f\"{v:5d} {k[0]:24s} {k[1]}\") "' ``` Expected: a short list. Anyone with a meaningful play count is someone to contact in Step 2. - [ ] **Step 2: Contact each remaining active user individually** Expected pattern from the baseline: a small number of stragglers, plus **Tizen (Samsung TV)** and **PlayStation** users — 3 plays out of 600 at plan time. - Tizen: point them at the Moonfin client, or a $30 Fire TV / Google TV stick - PlayStation: there is **no native Jellyfin app**. Offer the same streaming-stick suggestion. - [ ] **Step 3: Announce the Plex end date** Give at least two weeks' notice to everyone still active on Plex. - [ ] **Step 4: Remove the Plex connections from the *arrs** - Sonarr → Settings → Connect → delete the **Plex** connection - Radarr → Settings → Connect → delete the **Plex** connection - Bazarr → Settings → disable the Plex integration Leave the Jellyfin connections from Task 15 in place. - [ ] **Step 5: Verify imports still trigger Jellyfin refreshes** Trigger a rename in Radarr, then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker logs --tail 30 jellyfin | grep -i 'refresh\|library'" ``` Expected: a refresh entry. Nothing should have broken by removing the Plex connections. --- # PHASE 7 — Decommission --- ### Task 23: Stop Plex and soak **Files:** none — live operation. - [ ] **Step 1: Final watch-state sync while Plex is still running** This is the last chance to capture anything watched on Plex. JellyPlex-Watched needs the live PMS API. ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker restart jellyplex-watched && sleep 180 && docker logs --tail 80 jellyplex-watched" ``` Expected: a clean sync with no errors. - [ ] **Step 2: Stop and disable the service** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo systemctl stop plexmediaserver && sudo systemctl disable plexmediaserver && systemctl is-active plexmediaserver; systemctl is-enabled plexmediaserver" ``` Expected: `inactive` and `disabled`. - [ ] **Step 3: Stop the sync container** With Plex down, JellyPlex-Watched has nothing to talk to and will log errors forever. ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker stop jellyplex-watched" ``` - [ ] **Step 4: Soak for two weeks** The package and all 383G of data remain on disk. **Rollback during this window is a single command** (`sudo systemctl start plexmediaserver`). Do not proceed to Task 24 until two weeks have passed with no one asking for Plex back. --- ### Task 24: Remove Plex and reclaim its storage **Files:** - Modify: `stacks/streaming/docker-compose.yml` (remove `jellyplex-watched`) - Modify: `stacks/media/docker-compose.yml` (remove `tautulli`) - Modify: `Caddyfile` (remove the `tautulli` site block) - [ ] **Step 1: Archive the Plex data before removing anything** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo tar czf /storage1/labdata/plex-final-$(date +%Y%m%d).tar.gz -C /storage1/labdata plex && ls -lh /storage1/labdata/plex-final-*.tar.gz" ``` This takes a long time — 383G of mostly-incompressible thumbnails. Expected: a tarball of roughly 300G+. **Keep it 30 days.** Set a calendar reminder to delete it. - [ ] **Step 2: Remove the package** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo pacman -Rns --noconfirm plex-media-server && which plexmediaserver || echo 'plex removed'" ``` Expected: `plex removed`. - [ ] **Step 3: Reclaim the data directory** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h /storage1 | tail -1 && sudo rm -rf /storage1/labdata/plex && sudo rm -f /var/lib/plex && df -h /storage1 | tail -1" ``` Expected: `/storage1` available space increases by roughly **383G**. - [ ] **Step 4: Remove jellyplex-watched from the compose file** In `stacks/streaming/docker-compose.yml`, delete the entire `jellyplex-watched` service block. It has no purpose without Plex. Also remove these now-dead lines from `stacks/streaming/stack.env`: ``` PLEX_URL, PLEX_TOKEN, JELLYFIN_BASEURL, JELLYFIN_TOKEN, SYNC_FROM_PLEX_TO_JELLYFIN, SYNC_FROM_JELLYFIN_TO_PLEX, DRYRUN, SLEEP_DURATION, USER_MAPPING ``` Keep `TZ`, `PUID`, `PGID`, and every `POSTGRES_*` and `JWT_SECRET` value. - [ ] **Step 5: Remove Tautulli** Tautulli is Plex-only and now monitors nothing. In `stacks/media/docker-compose.yml`, delete the `tautulli` service block (lines 110-121 at plan time). In `Caddyfile`, delete the `tautulli.ginnoir.com` block (lines 196-205 at plan time). - [ ] **Step 6: Validate both compose files and the Caddyfile** ```bash docker compose -f stacks/streaming/docker-compose.yml config --quiet && docker compose -f stacks/media/docker-compose.yml config --quiet && echo "COMPOSE OK" docker run --rm -v "${PWD}/Caddyfile:/etc/caddy/Caddyfile:ro" caddy:latest caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile ``` Expected: `COMPOSE OK` and `Valid configuration`. - [ ] **Step 7: Commit and push** ```bash git add stacks/streaming/docker-compose.yml stacks/streaming/stack.env stacks/media/docker-compose.yml Caddyfile git commit -m "chore: decommission plex Plex is removed from valhalla. Drops jellyplex-watched (nothing left to sync from), tautulli (Plex-only) and the tautulli Caddy site." git push origin main ``` - [ ] **Step 8: Verify the containers are gone** Wait 5 minutes for Portainer to poll, then: ```bash ssh -o BatchMode=yes ginnoir@valhalla "docker ps -a --format '{{.Names}}' | grep -E 'tautulli|jellyplex' || echo 'both removed'" ``` Expected: `both removed`. If they linger, Portainer may need a manual redeploy of the affected stack from the UI. - [ ] **Step 9: Remove the stale Tautulli config** ```bash ssh -o BatchMode=yes ginnoir@valhalla "sudo rm -rf /config/tautulli && echo removed" ``` --- ### Task 25: Clean up documentation and monitoring **Files:** - Modify: `CLAUDE.md` - Modify: `plans/homelab-improvements/tasks/TB-027-jellyfin.md` - Modify: `plans/homelab-improvements/tasks/TB-016-document-plex.md` - Regenerate: `bookmarks-domains.html`, `bookmarks-ports.html` - [ ] **Step 1: Replace the Plex section in CLAUDE.md** Delete the entire `### Plex Media Server` block under **External services (not in compose)**. If no other external service is documented there, remove that heading too. Add to the stack table in the **Layout** section: ``` │ ├── streaming/ # jellyfin, jellystat + postgres, wizarr ``` - [ ] **Step 2: Add a Jellyfin note to CLAUDE.md** Under **Known quirks / gotchas**: ```markdown - **Jellyfin storage is deliberately split.** SQLite config lives on the root SSD at `/config/jellyfin`; metadata, cache, trickplay and transcodes bind to `/storage1/labdata/jellyfin/*`. Trickplay needs an explicit sub-bind on `/config/data/trickplay` because Jellyfin 10.11 moved it there and it cannot be relocated from the UI. Do not "simplify" these into one mount — the root disk is 194G and has filled twice. - **Jellyfin has no SSO, on purpose.** `jellyfin-plugin-sso` only completes OIDC inside a browser; Roku, Fire TV and tvOS clients cannot use it. Jellyfin uses native accounts. This is a deliberate exception to the Authentik standard. - **The P100 cannot encode.** GP100 has no NVENC. Hardware *decoding* is enabled for trickplay generation; hardware encoding must stay off or every transcode fails. ``` - [ ] **Step 3: Close out the TB tasks** In `plans/homelab-improvements/tasks/TB-027-jellyfin.md`, fill in the decision table: ```markdown | **Decision** | Do it — completed | | **Notes** | Migrated to Jellyfin in stacks/streaming. See docs/superpowers/specs/2026-08-05-jellyfin-migration-design.md and docs/superpowers/plans/2026-08-05-jellyfin-migration.md | | **Date** | (fill in the completion date) | ``` In `plans/homelab-improvements/tasks/TB-016-document-plex.md`, mark it obsolete — Plex no longer exists to document. - [ ] **Step 4: Update Uptime Kuma** In the Uptime Kuma UI: - **Delete** the Tautulli monitor and any Plex monitor - **Add** an HTTP monitor for `https://jellyfin.ginnoir.com/health`, expected status `200`, interval 60s, with the ntfy notification attached - **Keep** the `valhalla root disk` push monitor from Task 3 - [ ] **Step 5: Regenerate bookmarks** ```bash pwsh -File scripts/gen-bookmarks.ps1 ``` - [ ] **Step 6: Verify no stale references remain** ```bash grep -rni "tautulli\|plexmediaserver\|plex media server" . --include="*.md" --include="*.yml" --include="Caddyfile" | grep -v "docs/superpowers/" ``` Expected: **no output**. Matches inside `docs/superpowers/` are historical records of this migration and should stay. - [ ] **Step 7: Commit and push** ```bash git add CLAUDE.md bookmarks-domains.html bookmarks-ports.html plans/homelab-improvements/tasks/ git commit -m "docs: retire plex, document the streaming stack Plex is gone from valhalla. Documents the Jellyfin storage split, the deliberate absence of SSO, and the P100's lack of NVENC. Closes TB-027 and obsoletes TB-016." git push origin main ``` - [ ] **Step 8: Final verification** ```bash ssh -o BatchMode=yes ginnoir@valhalla "df -h / /storage1 | grep -v Filesystem; docker ps --filter label=com.docker.compose.project=streaming --format '{{.Names}}\t{{.Status}}'" curl -s -o /dev/null -w 'jellyfin: %{http_code}\n' https://jellyfin.ginnoir.com/health ``` Expected: root disk healthy, `/storage1` reclaimed by ~383G, four `streaming` containers `Up` (jellyfin, jellystat, jellystat-db, wizarr), and Jellyfin returning `200`. --- ## Rollback summary | Up to and including | How to roll back | |---|---| | Task 22 | Plex is running and untouched. Do nothing — users simply keep using it. | | Task 23 | `sudo systemctl start plexmediaserver` | | Task 24, within 30 days | `sudo pacman -S plex-media-server`, then extract `/storage1/labdata/plex-final-*.tar.gz` and recreate the `/var/lib/plex` symlink | | After the tarball expires | No rollback. This is the point of no return. | ## Deferred, deliberately - **Intel Arc GPU purchase and passthrough** — gated on Task 20's evidence, needs its own plan - **Music and Books libraries** — out of scope per the design - **Seerr request-history migration** — accepted loss - **`/storage1` redundancy** — tracked separately