- Python 99.2%
- Dockerfile 0.6%
- Shell 0.2%
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> |
||
|---|---|---|
| safearr | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
| TODO.md | ||
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/ffprobeonPATH(SafeArr importspoisonreviewin-process forcreate_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.