2026-09-10 A Windows Executable Called Reacher

What I set out to do

Three things, in order: work out what services.mediaStack.forceConfig = true would actually overwrite before running it, deploy the merged orphaned-grab-recovery sweep from 2026-09-09 The Torrent Vanished and Nothing Noticed, and look at the two Reacher files that had been sitting in Sonarr’s queue with “dangerous file” warnings.

What I actually did

forceConfig is aimed at the wrong file

The CHANGELOG warned that a force re-seed “replaces the whole file, so any qBittorrent settings you’ve tuned in the UI are lost.” I wrote that warning. It turns out to be pointed at the one file where it does not matter.

seedSnippet applies seed_force to every entry in secrets.fileMap, so forceConfig rewrites eight files, not one. I built .#rendered-configs and diffed each against the live deploy. Six are cosmetic or semantically identical: recyclarr.yml and komf/application.yml parse deep-equal (their 270-line diffs are comment stripping and key reordering), nginx.conf differs only in blank lines and header quoting, and the three *arr config.xml files are alphabetical reorderings. qBittorrent loses only lines it regenerates on boot.

Bazarr is the one that actually loses state. The generator emits no default language profile, so a force re-seed writes serie_default_profile: 389289554 -> null and the same for movies, while leaving *_default_enabled: true. Profile 389289554 is “Subtitles” (en), confirmed live against Bazarr’s API. The result would be that every series and movie added from then on gets no language profile, so Bazarr never searches subtitles for it, silently, for as long as it takes to notice. The OpenSubtitles and AniDB credentials turned out to be safe (all three non-empty in .env, so the activation re-injects them), which was the thing I actually expected to be the problem.

So: don’t use forceConfig. Delete the single qBittorrent conf and switch, which is strictly narrower. One caveat that was not written down anywhere: qBittorrent has to be stopped first, or it rewrites the file from memory on shutdown and quietly undoes the seed.

Two side findings. generate.nix sets Radarr’s Branch = "main" when Radarr’s release branch is master, which is why the live file had drifted back. And the deployed config/komf/application.yml is the repo’s reference copy, still carrying its own “NOT DEPLOYED, EDITING THIS FILE HAS NO EFFECT” banner. Seed-if-absent found a file there and never displaced it. Content-equivalent, so KOMF is fine, but the banner is lying.

The deploy

just deploy bumped the lock to ed8d4eb. The sweep did not actually run until I restarted missing-search: compose had not recreated the container because only bind-mounted scripts changed, so the running loop was still executing pre-merge bash. Worth remembering, because it looks exactly like a successful deploy from the outside. After the restart the banner reads Orphaned grab recovery: true (lookback 168h, min age 60m) and both sweeps run clean.

The Reacher files are malware

Not misnamed videos. file(1):

Reacher.S04E08...NTb.exe   -> MZ..  PE32+ executable for MS Windows, x86-64
Reacher S04E07...FLUX.zipx -> PK..  Zip containing exactly one 1.06 GB .exe

Two ~1 GB Windows executables, one zipped to hide the extension. The size is the technique: pad past the ceiling above which many AV engines decline to scan, while still looking plausible for an episode. Sonarr’s dangerous-file check was the only thing that stopped the import, and it works, but the failure mode after that is bad: the item parks at importPending permanently, never fails, so autoRedownloadFailed never fires and the episode just reads as missing forever. Stuck since Sep 5.

Grab history split cleanly by indexer. E01 through E06 came from EZTV at 2.9-3.3 GB. E07 and E08 came from Nyaa.si at ~1 GB. Nyaa is an anime tracker whose Cardigann definition also exposes a Live Action section, and NYAA_CONFIG had cat-id: 0, All categories.

The part I did not expect, and only found because the re-search came back empty for E08: both fakes were posted before the episode aired. E07 was grabbed Sep 5 and aired Sep 9. E08 was grabbed Sep 9 and airs Sep 16, six days from now. It has never aired. That is the entire business model rather than a detail: a pre-air upload has no genuine release to compete with, so it wins by default however badly it scores. And the *arrs will happily grab an unaired episode when a matching release appears, so “monitored, no file, and a release exists” is not evidence the release is real.

What was striking

The empty search result for E08 was the most informative thing that happened all day, and I nearly filed it as “nothing available yet.” Checking airDateUtc turned a plausible non-answer into the sharpest evidence in the whole investigation.

The other one: I made a design call to leave indexer priority unreconciled, reasoning it was a live tuning knob rather than declared state. Then I searched the vault and found Persist Prowlarr Indexer Priorities in setup-prowlarr.sh, open since May, which had predicted that exact change and called it wrong in advance:

A future fix to honor that contract will overwrite the priority back to 25.

Those priorities are not casual defaults. They were set live in May after dead-swarm 1337x re-uploads stalled six Industry episodes at 0 peers, and they survived only because add_indexer skipped existing indexers. Leaving priority unmanaged would have preserved a state where docker compose down -v silently resets all of them. I reversed the decision and encoded them. Consulting the vault before deciding, rather than after shipping, was worth more than the code.

Third: testing the reconcile jq against the live Prowlarr object caught a bug my reasoning had missed entirely. ($customConfig | has(.name)) rebinds . to $customConfig, so .name is null and jq dies with Cannot check whether object has a null key. Same lesson as yesterday, learned again: the checks that found real bugs were the ones run against the live stack rather than argued from the code.

Outcome

  • PR #64: Nyaa cat-id to 1 (Anime); add_indexer reconciles instead of skipping; indexer priorities encoded at the call sites.
  • Fixed at the Prowlarr level rather than per app, because Radarr has no anime category split and genuinely uses Nyaa for anime films. Verified live: Nyaa now returns 0 results for “Reacher” and 75 for “Demon Slayer”.
  • Both executables deleted from disk and from qBittorrent, both releases blocklisted.
  • E07 re-downloading from 1337x and TPB at real sizes. E08 needs nothing; it airs on the 16th and missing-search will pick it up.

Follow-on

Two smaller generator bugs the forceConfig audit turned up got their own PR (#65): Radarr’s generated config.xml declared Branch = "main" when Radarr’s release branch is master (confirmed against the running services: Radarr master 6.3.0, Sonarr main 4.0.19, Prowlarr master 2.5.2), and config/komf/application.yml was a committed reference copy that a seed-if-absent deploy had been running for real while its own header claimed it was not deployed. Deleting it needed config/komf/.gitkeep in its place, because .gitignore had config/komf/* with a single negation for that file, and docker-compose.yml bind-mounts the directory. Docker creates a missing bind-mount source as root and KOMF runs as PUID/PGID, which is the trap config/seerr/.gitkeep already exists for. Fixed live too: deleted the stale file, switched, restarted KOMF, and it came back restarts=0 and reconnected to Komga’s SSE stream.

The review on #64 found a real bug I had introduced: desired_state set .enable = true on the update path as well as create, so an indexer disabled by hand would be switched back on by every orchestrator run. That is not academic. The Pirate Bay is documented as permanently broken by cookie replay, disabling it is the correct response, and re-enabling it would feed Sonarr and Radarr the 429s that start the backoff cascade recover-indexers.sh exists to undo. My own change would have fought another part of the stack. enable is now seeded on create and never reconciled, and a preserved disable is logged.

The review also asked for automated coverage, which was fair and matches the repo’s convention. add_indexer moved into scripts/prowlarr-indexers.sh so it is sourceable with no side effects, and prowlarr-indexers.test.sh drives every branch through a stubbed api_request: 24 cases behind a new flake check. Writing it produced the best moment of the day. My idempotency fixture independently reproduced the exact ($c | has(.name)) jq scoping bug from earlier in the session, silently turning that case into a false pass. Second time the same trap caught me in one sitting, which is about as strong an argument for the test as I could ask for.

Landed

#65 merged as 1ff9f5a. It conflicted with #64 in CHANGELOG.md, but purely additively: both branches had appended under ### Fixed with an empty merge base, so both sets of entries were kept. AGENTS.md merged on its own.

A second review round on #64 found two more things, and the serious one is a good lesson in reading a return in context. add_indexer returned 1 when a Cardigann schema was missing, and every call site is a bare statement under set -euo pipefail. So one indexer whose definitionName upstream renamed would abort the entire script: every indexer declared after it, plus the closing ApplicationIndexerSync, silently skipped. It also flatly contradicted a “graceful degradation” comment sitting a few lines below, which turned out to be true only of the create-request-fails path. I confirmed it with a five-line repro rather than reasoning about set -e semantics, which was the right call given how often I get that wrong. No path in the function returns non-zero now.

The second was subtler and is the more interesting one: reconciling priority means the three indexers that declare none get reset to 25 every run, so hand-tuning them stops sticking. That is the direction I want, but doing it silently is the same invisible loss that motivated encoding the values in the first place, just pointed the other way. It is logged now, matching the preserved-disable line.

Tests went 24 to 31. The set -e case is a real regression test: restoring the old return 1 makes it fail with an empty actual, which is exactly the signature of execution never reaching the next statement. Worth doing that check on every regression test I write, because a test that passes against the bug is worse than no test.

Both merged

#65 as 1ff9f5a, #64 as 969f79c. #64 took four review rounds, and the interesting thing is the shape of what each one found. Round one caught a behaviour bug I had reasoned my way into (.enable clobbering a manual disable). Round two caught a set -e interaction I would never have found by reading, because the return 1 looks completely innocent until you notice every call site is a bare statement. Round three was cosmetic. Round four found a shape-guard gap that turned out to be near-unreachable, because api_request uses curl -sf and short-circuits the only realistic path to a wrong-shaped body.

That last one is worth remembering as a counter-lesson to the rest of the day. The reviewer was right that the repo is deliberate about shape guards, and right that this spot lacks one, and it still was not worth acting on, because the upstream guard already covers the reachable case. Checking reachability took one command and turned “add a guard and a test” into “note it and merge”. The discipline that mattered all day was the same one in both directions: go look, do not reason.

2026-09-09 The Torrent Vanished and Nothing Noticed, Persist Prowlarr Indexer Priorities in setup-prowlarr.sh, Media Stack IaC Declarative Config Evaluation