- Python 96.8%
- Shell 2.6%
- Dockerfile 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Build and Publish Containers / build-and-push (push) Successful in 9s
Reviewed-on: #1 |
||
| .forgejo/workflows | ||
| app | ||
| deploy | ||
| mocks | ||
| scripts | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
| VERSION | ||
seerr-medic
Watches Seerr for issue reports and gets the bad file replaced: deletes it, blocklists the release, and starts a fresh search in whichever Sonarr or Radarr instance owns it — then comments back on the issue and waits to see the replacement land.
You report "the audio is German" in Seerr. A card appears in Telegram. You tap ✅. A minute later the release is blocklisted and a different one is downloading.
Everything lives in this one service: the Seerr polling, the arr calls, the Telegram bot, the approval state and the audit log. Nothing else to deploy.
Why blocklisting is the point
Deleting the file and re-searching is the obvious approach, and it does not work. Sonarr and Radarr rank releases the same way every time, so a plain re-search grabs the same bad release straight back. You have to blocklist it.
This is the one thing the off-the-shelf options do not do. It is why this exists.
How the arrs actually match a blocklist entry
Worth knowing, because it is not what you would guess, and it is why a blocklist can look like it worked and then not hold.
A blocklist row stores both a release title and a torrent info hash. The matcher picks one:
if (torrentInfo.InfoHash.IsNotNullOrWhiteSpace()) {
var byHash = _blocklistRepository.BlocklistedByTorrentInfoHash(seriesId, torrentInfo.InfoHash);
return byHash.Any(b => SameTorrent(b, torrentInfo)); // returns here — no title fallback
}
return _blocklistRepository.BlocklistedByTitle(seriesId, release.Title)...
So whenever the indexer hands back an info hash, the arr matches on the torrent and never looks at the release title. A row written without a hash matches nothing at all, and the same release sails straight back in.
Where the hash comes from decides everything:
TorrentInfoHash = message.TrackedDownload?.Protocol == DownloadProtocol.Torrent
? message.TrackedDownload.DownloadItem.DownloadId
: message.Data.GetValueOrDefault("torrentInfoHash", null)
MarkAsFailed(historyId) passes no TrackedDownload, so it can only use the
hash captured at grab time — which is empty when the indexer served a
.torrent URL rather than a magnet. Sonarr's own "Mark as Failed" button has
the same hole.
So seerr-medic takes two routes, in this order:
- Queue —
DELETE /api/v3/queue/{id}?blocklist=true, used whenever the download is still in the queue. This hands the arr aTrackedDownload, so the hash is always recorded. Strictly better; always preferred. - History —
POST /api/v3/history/failed/{id}otherwise. Works, but may produce a hashless row.
After blocklisting it reads the blocklist back and checks two things: that a row appeared at all, and whether a history-route block on a torrent went in without a hash. When it did, the Seerr comment and the Telegram card say the block may not hold rather than claiming success. There is no API to write a hash onto an existing row, so being honest about it is the best available answer.
Order of operations
Delete the file before blocklisting. Marking a grab failed makes the arr re-search immediately, and a search run while the old file is still on disk is evaluated as an upgrade — so the same-quality replacement you actually want gets rejected. Delete first and the search sees a missing episode.
seerr-medic also always issues its own search rather than relying on the arr's automatic one, which is gated behind the Redownload Failed setting. The command queue collapses the duplicate, so it costs nothing.
Scope: one episode, one season, or the whole show
Seerr's issue form asks for an affected season and an affected episode, and what it comes back with decides how wide the remediation goes:
| Reported | Scope | What happens |
|---|---|---|
| Season 2, episode 5 | episode | That one file, then EpisodeSearch. |
| Season 2, all episodes | season | Every file in season 2, then one SeasonSearch. |
| All seasons | series | Every season that holds a file, one SeasonSearch each. |
The searches are season-wide rather than episode-by-episode on purpose. An
EpisodeSearch asks for that episode, and for an older show the only thing the
indexer has is often a season pack — which the episode search will not
consider. A SeasonSearch finds both, and the arr's command queue collapses
whatever duplicates a broad search produces.
A whole-show report covers a lot of files, so the card says how many and across which seasons before you tap. Seasons holding nothing are left out entirely: there is no file there to replace, and searching them would pull down parts of a show nobody asked for.
ALLOW_WHOLE_SERIES only applies in auto mode, where nothing asks first. In
manual mode the approval tap is the guard, so the card goes out and you
decide.
Skips are visible now
A skip used to be silent, which from the chat is indistinguishable from the service being down — the commonest way for a report to look like it vanished. So the two kinds are now separated:
- Working as intended — a subtitle issue, a reporter who is not on
ALLOWED_REPORTERS. Still silent; that is what you configured. - Wanted to act and could not — the show is not in Sonarr, the issue
carries no
tvdbId, the named season does not exist. These now get their own card saying which show and why, so a report never just disappears.
Either way the reason is in /data/audit.jsonl under the skipped event.
Why it polls Seerr instead of taking its webhook
Two reasons, either sufficient:
- The webhook payload cannot say which episode is broken. Its template
variables stop at
issue_id,issue_typeand the media ids;problemSeasonandproblemEpisodeexist only on the Issue record. - Seerr has exactly one webhook slot, and Home Assistant's Seerr
integration claims it and re-claims it aggressively — its
check_need_change()compares the stored URL, payload and type mask against its own on every config-entry load and overwrites all three. If you run that integration for its sensors, anything else pointed at Seerr's webhook gets silently evicted on the next HA restart.
So we poll GET /api/v1/issue?filter=open&sort=added every 60 seconds and read
the full record. Nothing contends for anything, and latency is well under what
anyone notices.
Telegram
The bot is the whole interface. In APPROVAL_MODE=manual (the default) every
remediation becomes one card:
🩺 Severance S02E05
Audio issue reported by azrae
“audio is german”
Found on sonarr — will delete 1 file, blocklist the release
and search again.
[ ✅ Redownload ] [ ✋ Leave it ]
That single message is then edited in place as things progress — approved, executing, waiting for the import, done — so the chat holds one entry per broken episode rather than five, and scrolling back shows final states rather than a trail of superseded updates.
Commands: /status (health, mode, counts), /pending (re-post anything
waiting on you), /recent, /help.
Transport. TELEGRAM_MODE=polling is the default and long-polls
getUpdates — entirely outbound, so nothing has to be publicly reachable.
webhook mode is supported but needs a genuinely public HTTPS URL with a real
certificate, which medic.liniz.org is not.
Authorisation matters here. Knowing the bot's @name is enough to message
it, so button presses are checked against TELEGRAM_ALLOWED_USER_IDS. With
that empty, a private chat authorises only its own user; a group chat
authorises nobody. It fails closed on purpose — the buttons delete files.
Telegram permits one poller per bot token, so this needs its own bot from BotFather rather than sharing mtg-bot's.
Flow
Seerr issue filed
|
v
poll (60s) --> already seen? --> stop
|
v
policy: issue type, reporter allowlist, is a season/episode named?
|
v
resolve item across SONARR / RADARR / RADARR4K
(the instance that actually has the file wins)
|
v
APPROVAL_MODE=manual? --> Telegram card with buttons --> you decide
| |
| auto v
+-----------------------------------------> approve / deny
|
v
delete file(s) -> mark grab failed (blocklist +
search) -> comment on the issue
|
v
watch for the replacement to import, then resolve
the issue and edit the card
Surviving restarts
Everything with a lifetime lives in SQLite, not in memory: issues already seen, remediations waiting on your approval, remediations waiting for a replacement to import, each card's chat and message id, and the Telegram update offset. Restart the pod mid-flight and every one of those resumes — including editing the right card.
Configuration
Everything is environment variables; see .env.example for the annotated set. The ones worth knowing:
| Variable | Default | Notes |
|---|---|---|
ARR_INSTANCES |
SONARR,RADARR |
Each name needs <NAME>_URL and <NAME>_API_KEY. Kind is inferred from the prefix, so RADARR4K works with no extra wiring. |
APPROVAL_MODE |
manual |
manual sends the card and waits. auto just does it. |
TELEGRAM_BOT_TOKEN |
(empty) | Empty disables Telegram entirely; approvals then only come from the HTTP API. |
TELEGRAM_CHAT_ID |
(empty) | Where cards go. Positive = private chat, negative = group. |
TELEGRAM_ALLOWED_USER_IDS |
(empty) | Who may press buttons. Empty + group chat = nobody. |
TELEGRAM_MODE |
polling |
polling, webhook, or off. |
TRIGGER_ISSUE_TYPES |
1,2,4 |
Video, Audio, Other. Subtitles (3) is excluded — that is Bazarr's job, not a reason to throw the release away. |
ALLOW_WHOLE_SERIES |
false |
Only consulted in auto mode. See Scope. |
ALLOWED_REPORTERS |
(empty) | Empty means anyone who can file an issue can trigger this. Fine in manual mode; think hard in auto. |
CONFIRM_REPLACEMENT |
true |
Hold the issue open until the new file imports, then resolve it with the release name. |
DRY_RUN |
false |
Log and record everything, change nothing. Deploy with this on. |
SEED_ON_FIRST_RUN |
true |
First start records existing open issues without acting, so a fresh deploy does not stampede a backlog. |
Endpoints
| Method | Path | |
|---|---|---|
GET |
/health |
Liveness. Never touches the network. |
GET |
/health/detailed |
Per-backend checks including Telegram; 503 if any is down. |
GET |
/remediations?status=&limit= |
Recent or filtered. |
GET |
/remediations/{id} |
One record, including every action taken. |
POST |
/remediations/{id}/approve?actor= |
Idempotent — a double-tap is not an error. |
POST |
/remediations/{id}/deny?actor= |
|
POST |
/issues/{id}/consider |
Run one issue through by hand. Useful for testing, or retrying after fixing config. |
POST |
/telegram/webhook |
Only in webhook mode. Authenticated by Telegram's own secret header. |
GET |
/config |
Effective config, secrets omitted. |
API_SHARED_SECRET guards everything except /, /health and the Telegram
webhook, via X-Api-Key or ?token=. Leave it empty while the Service is
ClusterIP-only. Telegram button presses do not use it — they are authorised by
user id.
Logs
Two streams, both structured:
- stdout, one JSON object per line — fluentbit already ships this off the node.
/data/audit.jsonl, append-only, one object per state change. Never rewritten. This is the record of what was actually deleted and blocklisted, and it includes refused Telegram presses.
kubectl exec -n media deploy/seerr-medic -- tail -f /data/audit.jsonl
Local development
python -m venv .venv && ./.venv/Scripts/python.exe -m pip install -r requirements-dev.txt
./.venv/Scripts/python.exe -m pytest -q
cp .env.example .env && ./.venv/Scripts/python.exe -m uvicorn app.main:app --reload --port 8080
The tests use fakes shaped like the real Sonarr, Radarr and Bot API payloads,
so they cover the things that actually break: season-pack deduplication,
picking radarr4k over radarr when only the 4K copy has the file, refusing
whole-series scope, a stranger pressing the button, and a card that has become
too old to edit.
Deploying
See deploy/README.md.