No description
  • Python 96.8%
  • Shell 2.6%
  • Dockerfile 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Pablo 3d3b3f3b5b
All checks were successful
Build and Publish Containers / build-and-push (push) Successful in 9s
Merge pull request 'staging' (#1) from staging into main
Reviewed-on: #1
2026-09-20 22:25:37 -05:00
.forgejo/workflows fixed blocklist 2026-08-20 13:36:58 -05:00
app whole season replacer 2026-09-20 22:24:28 -05:00
deploy whole season replacer 2026-09-20 22:24:28 -05:00
mocks fixed blocklist 2026-08-20 13:35:07 -05:00
scripts whole season replacer 2026-09-20 22:24:28 -05:00
tests whole season replacer 2026-09-20 22:24:28 -05:00
.dockerignore fixed blocklist 2026-08-20 13:35:07 -05:00
.env.example whole season replacer 2026-09-20 22:24:28 -05:00
.gitignore first commit 2026-08-20 10:18:09 -05:00
docker-compose.yml fixed blocklist 2026-08-20 13:35:07 -05:00
Dockerfile first commit 2026-08-20 10:18:09 -05:00
pyproject.toml first commit 2026-08-20 10:18:09 -05:00
README.md whole season replacer 2026-09-20 22:24:28 -05:00
requirements-dev.txt first commit 2026-08-20 10:18:09 -05:00
requirements.txt first commit 2026-08-20 10:18:09 -05:00
VERSION whole season replacer 2026-09-20 22:24:28 -05:00

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:

  1. Queue — DELETE /api/v3/queue/{id}?blocklist=true, used whenever the download is still in the queue. This hands the arr a TrackedDownload, so the hash is always recorded. Strictly better; always preferred.
  2. 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_type and the media ids; problemSeason and problemEpisode exist 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.