Skip to content

seerr-approver

seerr-approver is a Telegram bot that receives media request notifications from two request portals and lets the admin approve, decline, or manage them directly from Telegram — no browser required. It also monitors for requests where no indexer source was found and sends actionable alerts.

Instance Request portal Media server
jellyseerr Jellyseerr Jellyfin
seerr Seerr Emby

Both instances are managed by the same bot process. Each Telegram card identifies which instance the request came from.


How It Works

The bot runs two concurrent loops: a FastAPI webhook server (receives events from Jellyseerr and Radarr/Sonarr) and a Telegram long-polling loop (receives button taps from the admin).

graph LR
    JFS[Jellyseerr/Seerr] -->|1. webhook| Bot[seerr-approver :8080]
    Radarr -->|1. webhook| Bot
    Bot -->|2. Telegram card + buttons| Admin[Admin Telegram]
    Admin -->|3. tap button| Bot
    Bot -->|4. approve / decline / re-request| JFS
    Bot -->|5. library refresh on MEDIA_AVAILABLE| MS[Jellyfin / Emby]
Hold "Alt" / "Option" to enable pan & zoom

Telegram Cards

Pending Approval

Sent when Jellyseerr fires MEDIA_PENDING. Shows media info and inline action buttons.

🎬 Dune: Part Two (2024)
Movie · via Jellyseerr
👤 alice
🎯 Any - HD-1080p
📁 /data/media/movie

[ ✅ Approve ]  [ ❌ Decline ]  [ ✏️ Edit ]

The Edit button opens a submenu to change quality profile, root folder, or reassign the request to a different user before approving.

No Source Found

Sent when a request was approved but no indexer source was found. Triggered by:

  • Jellyseerr MEDIA_FAILED webhook (Jellyseerr could not reach Radarr/Sonarr)
  • Radarr/Sonarr DownloadFailure or ManualInteractionRequired webhook
  • Background monitor: approved request with no grab after 5 minutes
⚠️ No source found
🎬 Dune: Part Two (2024)
Movie · via Jellyseerr
👤 alice
🎯 Any - HD-1080p
📁 /data/media/movie

[ 🗑️ Delete ]  [ 🔄 Re-request ]  [ ⏳ Leave It ]

No-Source Monitor

Radarr and Sonarr have no webhook for "search returned zero results". The bot works around this by combining two signals:

graph TD
    A[MEDIA_APPROVED webhook] -->|store tmdbId + approved_at| P[pending_approvals dict]
    B[Radarr Grab webhook] -->|source found → remove| P
    C[Background task every 2 min] --> D{age > 5 min?}
    D -->|yes| E[GET /api/v3/log on Radarr/Sonarr]
    E --> F{0 reports found\nafter approved_at?}
    F -->|yes| G[Send nosource card]
    F -->|no| H[Keep watching up to 30 min]
    D -->|no| I[Too soon, skip]
Hold "Alt" / "Option" to enable pan & zoom
Signal Meaning
Grab webhook received Source found — cancel nosource watch
No Grab + log confirms 0 reports No source — send alert
No Grab + no log match after 30 min Timed out — drop silently

Restart recovery

On startup the bot queries both Seerr instances for all currently approved requests and re-populates the watch list. Only requests approved within the last 30 minutes are recovered — anything older has already exceeded the monitoring window. Duplicate nosource cards are prevented by a deduplication set that covers both the webhook and monitor paths.


Media Removal

Requesters can ask for media they requested to be deleted. Every removal is confirmed by the admin in Telegram before any file is touched — seerr has no user-level delete permission, so the bot brokers it with the admin API key.

graph TD
    A[User: seerr web UI<br/>Report an issue] -->|ISSUE_CREATED webhook| Bot
    B[User: /mine in Telegram] -->|tap Remove| Bot
    Bot[seerr-approver] -->|removal card| Admin[Admin Telegram]
    Admin -->|🗑️ Delete| Bot
    Bot -->|DELETE /api/v1/media/id/file| Seerr[Seerr / Jellyseerr]
    Seerr -->|deleteFiles=true| Arr[Radarr / Sonarr]
    Bot -->|library refresh| MS[Jellyfin / Emby]
    Bot -->|resolve issue| Seerr
Hold "Alt" / "Option" to enable pan & zoom

Two channels

Open the title in seerr → Report an issue → Other, and write a message containing one of the removal keywords (default: remove, delete, watched, done with).

Requires the Issue Reported notification type on the webhook agent. Issues whose text does not match a keyword are forwarded to the admin as an ordinary issue notification rather than being treated as removal requests.

This channel works for every user with no setup — seerr already knows who filed the issue.

/link <seerr username> → the admin approves once → /mine lists the user's available titles with a Remove button on each.

Users who already set a Telegram chat ID in their seerr notification settings (Settings → Notifications → Telegram) are linked automatically on first use, with no approval step.

Removal card

🗑️ Removal requested
🎬 Dune: Part Two (2024)
Movie · via Jellyseerr
👤 alice
💬 done with this, please remove
💾 7.8 GB

[ 🗑️ Delete ]  [ ❌ Keep ]

The size comes from Radarr's sizeOnDisk (Sonarr: statistics.sizeOnDisk), so the admin can see what a deletion actually frees.

Ownership is advisory

A user can file an issue on any available title, not only their own requests. When the reporter is not the original requester the card shows ⚠️ originally requested by <name> rather than dropping the request — shared households have legitimate cases. The /mine channel is strictly scoped: the list is re-derived from seerr on every tap, so a stale or forged button cannot delete someone else's title.

What deletion does

DELETE /api/v1/media/{mediaId}/file makes seerr call Radarr DELETE /api/v3/movie/{id}?deleteFiles=true (Sonarr: removeSeries), sets the media status to DELETED, and resets its service data. The bot then triggers a library refresh so the entry disappears from Jellyfin/Emby, and resolves the originating issue.

Titles can be re-requested

seerr's duplicate-request guard only blocks requests whose existing request is not DECLINED or COMPLETED. A fulfilled request is COMPLETED, so a deleted title can be requested again normally.


Manual Library Scan

The /scan command lets the admin trigger a library scan on demand without opening the media server UI.

Flow:

  1. /scan → bot asks which server: Emby or Jellyfin
  2. Tap server → bot lists all configured libraries plus an All libraries option
  3. Tap a library → bot triggers the refresh and confirms
Which server do you want to scan?

[ Emby ]  [ Jellyfin ]
Jellyfin libraries

• Movies
• TV Shows
• Anime

Tap one to scan:

[ 📚 All libraries ]
[ Movies ]
[ TV Shows ]
[ Anime ]

Targeting a specific library calls POST /Items/{libraryId}/Refresh directly. "All libraries" falls back to POST /Library/Refresh.


Library Refresh

When a MEDIA_AVAILABLE webhook is received, the bot automatically triggers a library scan on the corresponding media server so the title appears without waiting for a scheduled scan.

Instance Media server Auth header
jellyseerr Jellyfin Authorization: MediaBrowser Token="<key>"
seerr Emby X-Emby-Token: <key>

Different auth headers

Jellyfin deprecated X-Emby-Token in 10.11 and will remove it in v12. The bot uses the correct header for each server type.

The refresh targets only the relevant library rather than triggering a full scan:

  1. GET /Library/VirtualFolders — find the library where CollectionType is movies (for movies) or tvshows (for TV)
  2. POST /Items/{libraryId}/Refresh — scan only that library
  3. Falls back to POST /Library/Refresh (full scan) if the specific library cannot be found

A Telegram message is sent on success:

✅ 🎬 Dune: Part Two (2024) is now available — movie library refresh triggered on Jellyfin.

Actions

Re-request

Tapping 🔄 Re-request walks through a two-step picker:

  1. Choose a quality profile
  2. Choose a root folder

Then:

  1. The original request is deleted from Jellyseerr
  2. A new request is created with the same TMDB ID, same requester, the chosen profile, and the chosen folder

This lets the admin retry with a lower quality profile or a different library path on behalf of the original requester without manual intervention.

Edit (pending approval)

From the Edit submenu on a pending card:

Option What it does
Quality Profile Changes the Radarr/Sonarr quality profile before approving
Root Folder Changes the destination folder
Request As Reassigns the request to a different Jellyseerr user

Deployment

  • Namespace


    bots

  • Source


    gitea.hdhomelab.com/cicd/seerr-approver-bot

  • Config


    flux/apps/noah/bots/seerr-approver/

  • Port


    8080 (cluster-internal only, no ingress)


Configuration

Environment Variables

Env Var Source Value
TELEGRAM_BOT_TOKEN Vault secret Bot token from BotFather
TELEGRAM_CHAT_ID Vault secret Admin chat or group ID
SEERR_URL ConfigMap http://seerr.media.svc
SEERR_API_KEY Vault secret Seerr API key
JELLYSEERR_URL ConfigMap http://jellyseerr.media.svc
JELLYSEERR_API_KEY Vault secret Jellyseerr API key
RADARR_URL ConfigMap http://radarr.media.svc (override for direct Radarr access)
SONARR_URL ConfigMap http://sonarr.media.svc (override for direct Sonarr access)
JELLYFIN_URL ConfigMap http://jellyfin.media.svc:8096
JELLYFIN_API_KEY Vault secret Jellyfin API key for library refresh
EMBY_URL ConfigMap http://emby.media.svc:8096
EMBY_API_KEY Vault secret Emby API key for library refresh
DB_HOST Static PostgreSQL host (192.168.68.7)
DB_PORT Static (default: 5432) PostgreSQL port
DB_NAME Static Database name (seerr-approver-bot)
DB_USER Vault secret (ExternalSecret) PostgreSQL role username
DB_PASSWORD Vault secret (ExternalSecret) PostgreSQL role password
REMOVAL_KEYWORDS Static (optional) Comma-separated phrases marking an issue as a removal request. Default remove,delete,watched,done with

Vault Secrets

Create at path seerr-approver-bot in Vault:

Key Description
telegram-token Telegram bot token — BotFather /newbot
telegram-chat-id Chat ID of the admin/family Telegram group
seerr-api-key Seerr API key — Settings → API Keys
jellyseerr-api-key Jellyseerr API key — Settings → API Keys
jellyfin-api-key Jellyfin API key — Dashboard → API Keys
emby-api-key Emby API key — Settings → API Keys

DB credentials live at apps/psql/seerr-approver-bot (written by OpenTofu — do not create manually).

Database Provisioning

The bot stores Telegram↔seerr account links in PostgreSQL. Add an entry to tofu/tf-deploy/psql/locals.tf:

seerr-approver-bot = {
  create_password = true
  databases = {
    seerr-approver-bot = {}
  }
}

Then apply:

cd tofu/tf-deploy/psql
tofu init -backend-config=backend.pg.tfbackend
tofu plan -out plan.out
tofu apply plan.out

Credentials are written to Vault at apps/psql/seerr-approver-bot. The ExternalSecret in flux/apps/noah/bots/seerr-approver/ maps them into the pod as DB_USER and DB_PASSWORD.

Tables are created by the app

The cyrilgdn/postgresql provider has no table resource, so OpenTofu owns the role, database and grants while the bot applies its own DDL (CREATE TABLE IF NOT EXISTS) at startup. Same split as woot-deal-bot.

Not SQLite on a PVC

The PVCs here are NFS-backed, and SQLite depends on POSIX advisory locks that NFS implements unreliably — the failure mode is a corrupted database rather than a clean error. See SQLite on local-path.

See PostgreSQL Provisioning for the full pattern.


Request Portal Webhook Setup

Configure in both portals under Settings → Notifications → Webhook:

Setting Value
Webhook URL http://seerr-approver.bots.svc/webhook?instance=jellyseerr
Notification types Request Pending Approval, Media Approved, Media Auto-Approved, Media Failed, Media Available, Issue Reported
Setting Value
Webhook URL http://seerr-approver.bots.svc/webhook?instance=seerr
Notification types Request Pending Approval, Media Approved, Media Auto-Approved, Media Failed, Media Available, Issue Reported

Note

Without this the seerr-issue removal channel silently does nothing — the bot never receives the event.


Radarr / Sonarr Webhook Setup

Settings → Connect → Webhook → add:

Setting Value
URL http://seerr-approver.bots.svc/webhook/arr?instance=jellyseerr&type=radarr
Triggers On Grab, On Download Failure, On Manual Interaction Required
Setting Value
URL http://seerr-approver.bots.svc/webhook/arr?instance=jellyseerr&type=sonarr
Triggers On Grab, On Download Failure, On Manual Interaction Required

On Grab is required

The On Grab event is used to cancel the no-source watch when a release is successfully grabbed. Without it, the bot will send a false-positive nosource alert even when a download is in progress.


Commands

Command Description
/pending List all pending requests (awaiting approval) from both Seerr and Jellyseerr
/approved List approved-but-not-yet-available requests by title; tap one to load its action card (delete, re-request, or leave)
/scan Manually trigger a library scan on Emby or Jellyfin — pick the server, then pick a library
/link <seerr username> Ask the admin to link your Telegram account to a seerr user. Not needed if your seerr notification settings already carry your Telegram chat ID
/mine List your available titles, each with a button to request its removal

Command scoping

/pending, /approved and /scan are restricted to the admin chat (TELEGRAM_CHAT_ID) and are silently ignored elsewhere. /link and /mine are open to any chat — they authorise per action, against the requester's own seerr account. Admin-only callback buttons re-check the chat ID when tapped, so forwarding a card to another chat does not transfer its powers.