No description
  • Python 99.2%
  • Dockerfile 0.6%
  • Shell 0.2%
Find a file
ventoz91 7adf2de321 Add failsafe detection for silently-copied (non-hardlinked) releases
Fixing the Phase 17 mount topology doesn't guard against it (or
something like it) coming back unnoticed -- a copy fallback isn't an
exception and produces a byte-identical file, which is exactly how it
went undetected for two days. Adds quarantine.is_hardlinked(), an
immediate WARNING in apply_decision() on every approval, a new
verify.py audit module + `safearr verify` CLI command, and a dashboard
warning banner + poll-loop log line so a human doesn't have to go
looking for it. See TODO.md Phase 18.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 03:11:42 -06:00
safearr Add failsafe detection for silently-copied (non-hardlinked) releases 2026-08-13 03:11:42 -06:00
tests Add failsafe detection for silently-copied (non-hardlinked) releases 2026-08-13 03:11:42 -06:00
.dockerignore Add Docker Compose setup (safearr + poisonreview), update docs to match 2026-08-10 16:49:35 -06:00
.env.example Fix silent copy-instead-of-hardlink on release: collapse quarantine/library mounts into one 2026-08-13 02:40:03 -06:00
.gitignore Scaffold safearr package with phased TODO plan 2026-08-09 21:22:58 -06:00
docker-compose.yml Fix silent copy-instead-of-hardlink on release: collapse quarantine/library mounts into one 2026-08-13 02:40:03 -06:00
docker-entrypoint.sh Add Docker Compose setup (safearr + poisonreview), update docs to match 2026-08-10 16:49:35 -06:00
Dockerfile Fix quarantine_dir never matching Sonarr/Radarr's reported paths, add review links 2026-08-11 09:53:18 -06:00
pyproject.toml Implement the full quarantine -> review -> release/reject pipeline (Phase 1-4) 2026-08-10 16:40:57 -06:00
README.md QOL round for /stats and /decisions: time window, queue-time, confirms, stale-pending flag, CLI parity 2026-08-12 23:11:04 -06:00
TODO.md Add failsafe detection for silently-copied (non-hardlinked) releases 2026-08-13 03:11:42 -06:00

safearr

Human-reviewed quarantine for Sonarr and Radarr.

SafeArr reviews everything imported through a dedicated quarantine root folder, hands it to PoisonReview to generate screenshots + a review UI, and acts on the human decision:

  • Approve — hardlink into the Plex/Jellyfin library path
  • Reject — delete SafeArr's quarantined copy, tell Sonarr/Radarr to remove its own managed file, blocklist the release, trigger a new search

A wrong call can be corrected from the /decisions page in the web UI (recent approve/reject history): undoing an approve is a clean, full undo — Sonarr/Radarr's own file was never touched, so it just pulls the released copy back out of the library and runs the normal reject flow. Undoing a reject is best-effort only — the original bytes are typically already gone by the time anyone notices, so "retry" just clears the blocklist entry and searches again; whatever gets grabbed goes through a brand new review. See TODO.md Phase 15 for the reasoning.

The /stats page shows how the queue is doing: approve/reject counts and rate, average time a file sits in the queue, which titles keep getting flagged, and a per-release-group reject-rate breakdown (Sonarr/Radarr's reported release group on each file, when they report one) to spot a repeat-offender source — flagged automatically once a group's reject rate crosses 50% with at least 3 samples. Filterable to the last 7/30 days or all-time; safearr stats [--days N] gives the same numbers from the terminal. See TODO.md Phases 10 and 16.

What gets reviewed is decided entirely by Sonarr/Radarr root folder placement, not by rating/tag rules. Sonarr/Radarr fire their on-import hook only after the file is already sitting wherever they imported it — there's no pre-import hook — so the only thing that can gate "does a human need to look at this before Plex does" is where Sonarr/Radarr put it. Protecting a title (existing or new) means setting its root folder to SafeArr's quarantine directory — a one-time, per-title choice, either directly in Sonarr/Radarr's UI or via Seerr's Advanced Requests root-folder picker. Once set, every future import for that title routes through automatically and always gets reviewed. Anything imported to any other root folder is never touched — SafeArr doesn't even look at it. (RatingRules still exist and are still configurable in Settings, kept for a possible future secondary filter, but aren't consulted for this decision — see TODO.md Phase 7 for the full reasoning.)

SafeArr owns every Sonarr/Radarr/Plex opinion. It depends on PoisonReview for the generic "scan this file, get a human decision" part, and PoisonReview stays completely ignorant of what SafeArr does with that decision.

Status

Deployed and live as of 2026-08-11 — not just tested, actually running against a real Sonarr/Radarr install. 254 tests cover it, including a real webhook → quarantine → review → approve → release run over HTTP against a real ffmpeg-generated video: Sonarr/Radarr's Connect webhook or custom-script hook lands a file imported through the quarantine root in quarantine, PoisonReview generates the review, approving/rejecting through PoisonReview's UI gets picked up by a background poller (or immediately via safearr approve/safearr reject) and releases into the library, or discards + tells Sonarr/Radarr to remove its own copy + blocklists + searches again.

The background poller now also self-heals two failure modes discovered running this for real: a manual /scan run whose process died mid-way (container restart, OOM) auto-resumes from wherever it left off instead of sitting "running" forever, and a file Sonarr/Radarr imports into the quarantine root while SafeArr happens to be down (or the webhook POST just fails to deliver — Sonarr/Radarr don't retry those) gets picked up and queued for review on the next poll tick instead of silently sitting there unreviewed. That reconciliation logic had two real identity bugs across its first two versions — see TODO.md Phases 8 and 9 for what they were and how they're fixed. Phase 8's was caught before it ever shipped; Phase 9's (origin_path claimed as unique forever instead of only while active, which silently broke reject-then-redownload at the same path) was caught by the same external review roughly seven minutes after that exact deploy went live — about as homelab as software development gets.

Certification lookups and webhook payload shapes in integrations/{sonarr,radarr}.py have been verified against a real Sonarr/Radarr instance — see TODO.md's Phase 3 section. The reject path's blocklist_and_search() (marking a grab failed + searching again) and the downloadId/file-id fields it now depends on are still unverified against a live Download-event webhook — it wasn't worth actually failing a real grab on a live media server to check. Implemented defensively (degrades to a no-op blocklist + normal search, same as before) if those fields turn out to look different in practice.

No authentication on either app's web UI — safearr web and PoisonReview's own review UI bind to 0.0.0.0 inside their containers, and docker-compose.yml deliberately publishes both ports on the host's other interfaces (not just localhost), reachable from the rest of the LAN the same way Sonarr/Radarr/Plex already are here. Intentional for a trusted home network — put a reverse proxy + auth in front before exposing either past your router.

Also has a second, separate workflow: /scan searches your existing Sonarr/Radarr library and lets you manually run PoisonReview against something already added — read-only, doesn't quarantine or touch Plex/Jellyfin access at all, just for auditing what's already there. See TODO.md's Phase 6.

Setup/settings/dashboard UI: safearr web (binds to 127.0.0.1:8383 by default — no built-in auth, since it holds your Sonarr/Radarr API keys).

Docker: build-tested and running in production (docker compose build succeeded first try; the only fix needed after was PYTHONUNBUFFERED=1 so docker logs actually showed output).

Requirements

  • Python 3, ffmpeg/ffprobe on PATH (SafeArr imports poisonreview in-process for create_review(), which shells out to ffmpeg)
  • poisonreview (sibling package — local editable path dependency during development, sibling checkout at ../poisonreview)
  • Sonarr and/or Radarr, reachable via their HTTP API, with their root folder pointed at SafeArr's quarantine directory (not your real Plex/Jellyfin library path — see the setup page and TODO.md's Phase 3 note for why this is a hard requirement, not a suggestion)

Running it

Docker (recommended — starts both SafeArr and PoisonReview's review UI):

git clone https://git.ventoz.ca/trevor/poisonreview.git
git clone https://git.ventoz.ca/trevor/safearr.git
cd safearr
cp .env.example .env   # fill in QUARANTINE_HOST_PATH / LIBRARY_HOST_PATH
docker compose up -d --build

Then visit http://localhost:8383/setup. Point Sonarr/Radarr's Connect webhook (or Custom Script — safearr-sonarr-hook / safearr-radarr-hook console scripts if running outside Docker) at http://<safearr-host>:8383/webhook/sonarr (or /webhook/radarr).

Without Docker: pip install -e ../poisonreview -e . from this directory, then safearr web.