The Self-Healing Arr Stack: Why My Watchlist Silently Stopped Downloading

A real homelab incident — rotated API keys silently killed my Arr automation for days with zero errors. Here's the debugging story and the four patterns that make media automation immune to it: runtime keys, one consolidated script, an auto-detecting VPN-leak check, and a dead-man's-switch alert.

On this page
  1. The symptom: silence
  2. The root cause: a rotated key and a hardcoded copy of it
  3. Pattern 1: Never hardcode the key — read it at runtime
  4. Pattern 2: Consolidate — one script that heals, not three that rot
  5. Pattern 3: A VPN-leak check that finds your home IP by itself
  6. Pattern 4: A dead-man’s-switch so silence becomes an alert
  7. Bonus: when a GUI app caches the old key
  8. The takeaway

I built a fully automated Arr stack: watchlist a movie on my phone, and minutes later it’s downloaded, renamed, and showing in Plex. It worked beautifully — right up until it didn’t, and the way it failed is the most useful thing this stack ever taught me.

For several days, watchlisted titles just never showed up. No error. No alert. No crash. The apps were healthy, the VPN was up, the download client was idle and cheerful. Everything looked fine, and nothing was happening.

This post is the debugging story and — more usefully — the four patterns that came out of it. If you run any kind of Arr automation, these are the changes that turn “works until something rotates” into “heals itself and tells you when it can’t.”


The symptom: silence

The tell was the absence of anything. Movies I’d watchlisted sat un-grabbed. The download queue was empty. Radarr and Sonarr reported healthy. The reconcile cron that’s supposed to sync my watchlist into Radarr was scheduled and — according to cron — running fine every 15 minutes.

The trap here is that a healthy-looking stack and a working stack are not the same thing. Nothing was broken in the sense of throwing an error. The automation had simply, quietly, stopped doing its job.

Silent failure is the real enemy

A service that crashes gets noticed. A cron job that runs, gets rejected, swallows the rejection, and exits 0 will fail forever without a single alert. Most homelab automation fails this way — not with a bang, but with a try/except that eats the error.


The root cause: a rotated key and a hardcoded copy of it

The API keys had rotated. Radarr, Sonarr, and Prowlarr had all been issued new keys, and the only live copies of those keys lived in each service’s own config.xml.

My reconcile script had the old key baked in as a string. Every run, it sent that stale key, the app answered HTTP 401 Unauthorized, and the script’s error handling logged nothing and exited cleanly. Cron saw a successful run. I saw an empty queue.

Here’s the detail that pointed straight at the fix. I actually had three automation scripts. Two of them — the reconcile job and a separate anime-sorting job — hardcoded the key and both died the instant it rotated. The third, a monitor script, kept working perfectly. Why?

Because that one script read the key from config.xml at runtime and talked to the app over 127.0.0.1, where I’d set authentication to “Disabled for Local Addresses.” It never used the stale key at all. The rotation was invisible to it.

That contrast was the design spec for the rebuild.


Pattern 1: Never hardcode the key — read it at runtime

The single source of truth for a Servarr app’s API key is its own config.xml. Read it there, every run, and a rotation can never break you.

Pull the current key from config.xml at runtime (Python)

import re, pathlib

def arr_api_key(config_xml: str) -> str:
  text = pathlib.Path(config_xml).read_text()
  # config.xml has a single <ApiKey>...</ApiKey> element
  return re.search(r"<ApiKey>([^<]+)</ApiKey>", text).group(1)

RADARR_KEY = arr_api_key("/opt/arr/radarr/config/config.xml")
SONARR_KEY = arr_api_key("/opt/arr/sonarr/config/config.xml")

Even better, if your script runs on the same host as the apps, you may not need the key at all:

1Turn on local-address auth bypass2 min

In each app: Settings → General → Security, set Authentication Method to Forms (Login Page) and Authentication Required to Disabled for Local Addresses.

Now any request from 127.0.0.1 skips authentication. Your local cron script talks to http://127.0.0.1:7878 (Radarr) or :8989 (Sonarr) with no key at all — nothing to rotate, nothing to leak, nothing to hardcode. (Browser and off-LAN access still require login.)

The principle generalizes

Any credential your automation copies is a credential that can drift. Wherever you can, read the live value from its source of truth at runtime instead of storing a second copy. A key you never wrote down can’t go stale.


Pattern 2: Consolidate — one script that heals, not three that rot

Three scripts meant three things to keep in sync and three places for a bug to hide. I merged the reconcile job, the re-monitor sweep, and the anime autosort into one script that runs every 15 minutes and does the whole loop:

  • Reconcile the Plex/Jellyfin watchlist into Radarr and Sonarr (matching by TMDb/TVDB ID, not title — apps store variant titles like “Guardians of the Galaxy Vol. 2” without the colon, and title-matching silently misses them).
  • Re-monitor movies and shows that should be grabbed.
  • Sort adopted anime into the right libraries.
  • Remediate dead grabs (Pattern 3 below).
  • Run health checks and emit a heartbeat (Pattern 4).

One script, one cron entry, one place to reason about. The old scripts became thin shims that just exec the new one, so nothing that referenced them broke.

Auto-remediate the deadlocked grab

The nastiest stall: a 4K release grabs, then dies (“no connections” / stalled / missing files), but Radarr thinks the item already meets cutoff — so it won’t grab a live alternative. The queue looks busy; nothing progresses. My script tracks each queue item across runs, and if one is dead for two consecutive runs, it evicts and blocklists it and re-searches — which lets a working release through. (Manual escape hatch: force-grab a specific release with POST /api/v3/release {guid, indexerId}, which overrides the size/cutoff rejection.)


Pattern 3: A VPN-leak check that finds your home IP by itself

My torrent client runs inside a gluetun VPN container, so its traffic should never exit on my home WAN. The classic way to verify that is to compare the client’s exit IP against your home IP — but if you hardcode your home IP, the check silently goes stale the moment your ISP hands you a new address, and now you have a leak detector that can’t detect leaks.

The fix is to auto-detect. The NAS host sits outside the VPN namespace, so it can fetch its own current public IP every run and compare:

Auto-detecting leak check — no hardcoded home IP

# The HOST's own public IP (it's outside the VPN) = your current home WAN:
HOME_WAN=$(wget -qO- https://api.ipify.org)

# The download CLIENT's exit IP (it's inside gluetun):
QBIT_IP=$(docker exec gluetun wget -qO- https://api.ipify.org)

# If they match, traffic is leaking onto your home connection — alert!
[ "$HOME_WAN" = "$QBIT_IP" ] && echo "LEAK: qBittorrent is on the home WAN"

Because HOME_WAN is fetched fresh each run, the check keeps working across ISP IP changes. (You can add an optional CIDR allowlist for an ISP that rotates you within a known block.)

After any reboot, re-verify egress

A container restart or NAS reboot can bring the download client up before the tunnel. Bake the leak check into your automation so it runs continuously — don’t rely on a one-time manual check at setup.


Pattern 4: A dead-man’s-switch so silence becomes an alert

Every pattern above still leaves one hole: what alerts you when the script itself dies? A key rotation, a bad deploy, or the whole NAS going down all produce the same thing that started this whole saga — silence.

The answer is a push (dead-man’s-switch) monitor. Instead of a monitor reaching out to check a service, your script checks in every run. If the check-in stops arriving, the monitor alerts on the missing heartbeat. I use Uptime Kuma for this:

1Create a Push monitor in Uptime Kuma3 min

Add a new monitor of type Push. Set the heartbeat interval to 1800s — that’s double my 15-minute cron, so a single missed run is tolerated but a truly-dead script alerts within half an hour. Kuma gives you a push URL; store it in your automation’s env file (e.g. KUMA_PUSH_URL).

2Push status every run — up on success, down with a reason5 min

At the end of each run, the script pushes to that URL. A clean run pushes status=up. Any problem — a VPN leak, an Arr /health error, a queue item stalled over 12h — pushes status=down&msg=<the reason>, so the alert tells me what’s wrong, not just that something is.

Heartbeat push at the end of the run

# Clean run:
wget -qO- "$KUMA_PUSH_URL?status=up&msg=OK"

# Problem detected:
wget -qO- "$KUMA_PUSH_URL?status=down&msg=VPN_leak_qbit_on_home_wan"
3Get it on your phone with ntfy5 min

Attach a notification to the monitor so a down (or a missed heartbeat) actually reaches you. ntfy is the easiest: pick a hard-to-guess topic, subscribe to it in the ntfy phone app, and add it as a Notification in Kuma. Now a dead script buzzes your phone like a text. (Telegram, email-to-carrier SMS gateway, Twilio, and SMTP all work too — ntfy is just the least friction.)

Kuma gotcha worth knowing

Uptime Kuma has no REST API — it’s socket.io only — so scripting monitor creation is painful. Push monitors sidestep this entirely: you create one in the UI once, grab its push URL, and everything after that is a plain HTTP GET from any script or language.

Now the exact failure that started this — automation silently not running — is the one thing that’s guaranteed to page me.


Bonus: when a GUI app caches the old key

If a desktop app (mine is a Flutter dashboard) stores Arr keys in the OS keyring, a rotation leaves it holding a dead key too. Rather than re-enter keys by hand, a small helper reads the live keys from config.xml and updates the keyring item in place — the same “read from the source of truth” principle as Pattern 1, applied to a cached copy instead of a hardcoded one.


The takeaway

The bug wasn’t really the rotated key. The bug was three copies of a secret and no alarm for silence. The rebuild fixes both at the root:

  1. Read credentials at runtime (or skip them via local-address auth) — nothing to go stale.
  2. Consolidate to one script that also self-heals dead grabs.
  3. Auto-detect environmental facts (your WAN IP) instead of hardcoding them.
  4. Add a dead-man’s switch so “the automation stopped” becomes a push notification, not a week of quiet.

Apply those four and your media stack stops being a thing you babysit and starts being a thing that tells you when it needs you — which, ideally, is never.


Related posts:

Sources: Servarr Wiki, Uptime Kuma docs, gluetun, ntfy.

Comments

Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.