Skip to content

feat(webhooks): add Jellyfin webhook endpoint#2648

Closed
alchemyyy wants to merge 2 commits intoseerr-team:developfrom
alchemyyy:jellyfin-webhook
Closed

feat(webhooks): add Jellyfin webhook endpoint#2648
alchemyyy wants to merge 2 commits intoseerr-team:developfrom
alchemyyy:jellyfin-webhook

Conversation

@alchemyyy
Copy link

@alchemyyy alchemyyy commented Mar 6, 2026

Description

Adds a webhook endpoint at /webhook/jellyfin that receives push notifications from the Jellyfin Webhook Plugin, giving event-driven library sync instead of waiting for the next scheduled scan.

How it works

  1. The Jellyfin Webhook Plugin fires an HTTP POST when library events occur (e.g. ItemAdded, ItemUpdated).
  2. The endpoint authenticates the request via API key (query param or X-API-Key header). It also handles the plugin's default text/plain Content-Type by parsing it as JSON locally.
  3. It extracts one or more item IDs from the body (ItemId or ItemIds), then filters by NotificationType — only ItemAdded and ItemUpdated are processed; everything else (playback, auth, tasks) is acknowledged and ignored.
  4. For each item, it resolves the effective target: movies are processed directly, while episodes and seasons resolve up to their parent series so the entire show is rescanned.
  5. A collapse queue prevents redundant work when a full season lands and Jellyfin fires one webhook per episode. The first webhook starts processing; subsequent ones for the same series set a dirty flag and return immediately. When processing finishes, if the flag is set, it rescans once more with the latest state. Worst case for N simultaneous webhooks is 2-3 full scans.
  6. Processing reuses the existing JellyfinScanner internals (processJellyfinMovie / processJellyfinShow) — the same code path as scheduled scans — so availability status updates identically.

How Has This Been Tested?

  • Configured Jellyfin Webhook Plugin to POST ItemAdded and ItemUpdated events to /webhook/jellyfin?apiKey=<key>
  • Verified new movies and series become available in Seerr within seconds of Jellyfin scanning them in. NOTE: Jellyfin by default can take up to a couple minutes to scan new items in and fetch their metadata. The hook fires when this is completed.
  • Verified rapid-fire webhooks from a full season import collapse correctly
  • Verified non-processable event types return 200 with "Ignored" message
  • Verified invalid/missing API key returns 401

Screenshots / Logs (if applicable)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

This PR was written primarily by Claude Code.

Summary by CodeRabbit

  • New Features

    • Added Jellyfin webhook endpoint (POST) to process ItemAdded/ItemUpdated events, supporting single or batch IDs, JSON or text payloads, and API key auth via header or query. Returns per-item statuses (success, skipped, error) and uses 207 for mixed results.
  • Other Changes

    • Webhook routes bypass CSRF token checks so API-key authenticated webhooks are accepted; improved input validation and error reporting for webhook requests.

@alchemyyy alchemyyy requested a review from a team as a code owner March 6, 2026 18:54
@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

Adds a Jellyfin webhook API and server-side handling: OpenAPI spec entry, new Express webhook route with API-key auth and flexible body parsing, scanner-side processing with a collapse queue to coalesce rapid events, and CSRF bypass for webhook paths.

Changes

Cohort / File(s) Summary
OpenAPI Spec
seerr-api.yml
Adds POST /webhook/jellyfin with application/json and text/plain bodies (ItemId/ItemIds, NotificationType), responses (200, 207, 400, 401, 500), and two new security schemes webhookApiKeyHeader and webhookApiKeyQuery.
Route Mount
server/routes/index.ts
Mounts Jellyfin webhook router at /webhook/jellyfin before the regular auth middleware so webhook routes handle their own authentication.
Webhook Handler
server/routes/webhooks/jellyfin.ts
New POST handler: flexible parsing for text/* and JSON, API-key validation via apiKey query or X-API-Key header, supports single ItemId or ItemIds[], filters ItemAdded/ItemUpdated (or manual triggers), deduplicates IDs, calls scanner helper per item, returns per-item results and uses 207 for partial failures.
Scanner & Exports
server/lib/scanners/jellyfin/index.ts
Adds WebhookProcessResult interface, JellyfinScanner.processItemById(itemId) method, top-level processJellyfinItemById(itemId) helper, and a webhookCollapseQueue mechanism that coalesces rapid events by effectiveId with a dirty-flag rescan loop.
Server CSRF Handling
server/index.ts
Applies CSRF middleware conditionally so requests under /api/v1/webhook/ bypass CSRF (cookie still set for non-webhook requests), enabling API-key authenticated webhook endpoints.

Sequence Diagram

sequenceDiagram
    participant JF as Jellyfin Server
    participant WH as Webhook Handler
    participant SC as JellyfinScanner
    participant MS as Media Metadata Source

    JF->>WH: POST /webhook/jellyfin (ItemId(s), NotificationType)
    WH->>WH: Parse body (JSON or text/*)\nValidate API key
    WH->>WH: Deduplicate ItemId(s)
    loop per item
        WH->>SC: processJellyfinItemById(itemId)
        SC->>SC: Check webhookCollapseQueue (skip if active)
        alt not skipped
            SC->>SC: Register effectiveId in collapse queue
            loop until quiescent
                SC->>MS: Fetch item metadata (determine type/effectiveId)
                SC->>SC: Sync/process (movie/show, 4K handling)
                alt webhook arrives during processing
                    SC->>SC: Set dirty flag -> continue loop
                else
                    SC->>SC: No dirty -> exit loop
                end
            end
            SC->>SC: Cleanup collapse queue entry
        end
        SC->>WH: Return WebhookProcessResult (success|skipped|error)
    end
    WH->>JF: Respond 200 or 207 with per-item results
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Ears up, I heard a webhook sing,
Jellyfin bells and a gentle ping.
Events that jumble now softly fold,
Collapsing bursts in a hug so bold.
A rabbit cheers: no duplicate scold!

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a Jellyfin webhook endpoint. It is concise, specific, and clearly reflects the primary objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
server/routes/webhooks/jellyfin.ts (1)

75-141: Batched ItemIds can still rescan the same series once per item.

This code deduplicates only raw item IDs and then processes them serially. If one webhook carries several episode IDs for the same show, each processJellyfinItemById() call finishes before the next starts, so the collapse queue in server/lib/scanners/jellyfin/index.ts never sees overlap and the same series can be fully rescanned repeatedly. Consider collapsing the batch by effectiveId, or otherwise coalescing within the request before entering this loop.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/webhooks/jellyfin.ts` around lines 75 - 141, The loop currently
deduplicates only raw itemIds (uniqueIds) and then calls
processJellyfinItemById(itemId) for each, which allows multiple episodes from
the same series to trigger separate full rescans; change to coalesce by
effectiveId before doing heavy processing: first perform a lightweight resolve
step (create or use a function like resolveJellyfinEffectiveId or a lightweight
call to processJellyfinItemByIdResolver) that maps each itemId -> effectiveId,
build a map of effectiveId -> representative itemId (and track all original
itemIds per effectiveId), then iterate over only the unique effectiveIds calling
processJellyfinItemById once per effectiveId; for any other itemIds that were
collapsed into the same effectiveId, push a 'skipped' result referencing their
original itemId and itemName/itemType/effectiveId as appropriate so the response
still reflects every incoming itemId.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 7932-7940: The OpenAPI spec is missing the HTTP 500 response for
single-item failures even though server/routes/webhooks/jellyfin.ts returns 500
on processing errors; add a '500' response entry under the same operation in
seerr-api.yml describing the single-item failure (e.g., "Internal Server Error -
item processing failed") and reference the generic error response schema used
elsewhere (or define one if missing) so the contract documents the non-batch
error path; update the operation's responses list alongside
'200','207','400','401' to include this '500' entry.
- Around line 7904-7910: The OpenAPI spec incorrectly marks the endpoint as
unauthenticated (security: []) while the implementation in
server/routes/webhooks/jellyfin.ts requires an API key via X-API-Key or query
apiKey; update the contract to require API key auth: replace security: [] with a
reference to an API key security scheme (e.g. security: - ApiKeyAuth: []) and
ensure the components.securitySchemes includes an ApiKeyAuth definition that
accepts both header (name: X-API-Key, in: header) and query (name: apiKey, in:
query) or document both schemes explicitly so generated clients and Swagger UI
correctly show the header/query API-key requirement, and remove the
optional-only parameter mismatch for apiKey in the endpoint definition.

In `@server/lib/scanners/jellyfin/index.ts`:
- Around line 650-675: processJellyfinMovie and processJellyfinShow swallow
errors and only log them, so the webhook handler still logs success and returns
result; change the webhook path to propagate failures by making those helpers
return an explicit success/failure (e.g., boolean or throw on failure) or
rethrow their caught errors when running in webhook mode. Update the webhook
loop that calls processJellyfinMovie/processJellyfinShow to check the returned
outcome (or wrap the call and rethrow if the helper indicates failure) before
logging the success message or returning result so failures cause the webhook to
return an error response instead of reporting success.

In `@server/routes/index.ts`:
- Around line 49-50: The Jellyfin webhook route mounted via
router.use('/webhook/jellyfin', jellyfinWebhookRoutes) is still blocked by the
global csurf middleware applied in server/index.ts when
settings.network.csrfProtection is enabled; either move the mount so it executes
before the csurf middleware is mounted or explicitly exempt the webhook path
from CSRF checks (e.g., add a small middleware that bypasses/returns next()
without csurf for requests whose path startsWith '/api/v1/webhook/jellyfin' or
update the csurf configuration to ignore that route) and keep references to
router.use('/webhook/jellyfin', jellyfinWebhookRoutes), checkUser, csurf, and
settings.network.csrfProtection when making the change.

---

Nitpick comments:
In `@server/routes/webhooks/jellyfin.ts`:
- Around line 75-141: The loop currently deduplicates only raw itemIds
(uniqueIds) and then calls processJellyfinItemById(itemId) for each, which
allows multiple episodes from the same series to trigger separate full rescans;
change to coalesce by effectiveId before doing heavy processing: first perform a
lightweight resolve step (create or use a function like
resolveJellyfinEffectiveId or a lightweight call to
processJellyfinItemByIdResolver) that maps each itemId -> effectiveId, build a
map of effectiveId -> representative itemId (and track all original itemIds per
effectiveId), then iterate over only the unique effectiveIds calling
processJellyfinItemById once per effectiveId; for any other itemIds that were
collapsed into the same effectiveId, push a 'skipped' result referencing their
original itemId and itemName/itemType/effectiveId as appropriate so the response
still reflects every incoming itemId.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e8faea9f-f38b-404c-baab-12bebdda2321

📥 Commits

Reviewing files that changed from the base of the PR and between ff4ab21 and 2e3d883.

📒 Files selected for processing (4)
  • seerr-api.yml
  • server/lib/scanners/jellyfin/index.ts
  • server/routes/index.ts
  • server/routes/webhooks/jellyfin.ts

Comment on lines +650 to +675
if (item.Type === 'Movie') {
await this.processJellyfinMovie(item);
} else if (
item.Type === 'Series' ||
item.Type === 'Season' ||
item.Type === 'Episode'
) {
await this.processJellyfinShow(item);
} else {
throw new Error(`Unsupported item type: ${item.Type}`);
}

if (entry.dirty) {
this.log(
`Webhook: ${result.itemName} — dirty flag set during processing, re-scanning`,
'info'
);
}
} while (entry.dirty);

this.log(
`Webhook: Processed ${result.itemName} (${item.Type}${item.Type !== 'Movie' ? `, series: ${effectiveId}` : ''})`,
'info'
);

return result;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Propagate scan failures back to the webhook caller.

This branch reports success even when the actual sync failed. processJellyfinMovie() (Lines 147-203) and processJellyfinShow() (Lines 241-466) already catch and log their own exceptions, so missing provider IDs, Jellyfin API failures, etc. still fall through to the success log on Line 670 and return result on Line 675. The webhook endpoint will therefore reply success for some failed syncs. Please make the webhook path return an explicit outcome from those helpers, or rethrow in webhook mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/scanners/jellyfin/index.ts` around lines 650 - 675,
processJellyfinMovie and processJellyfinShow swallow errors and only log them,
so the webhook handler still logs success and returns result; change the webhook
path to propagate failures by making those helpers return an explicit
success/failure (e.g., boolean or throw on failure) or rethrow their caught
errors when running in webhook mode. Update the webhook loop that calls
processJellyfinMovie/processJellyfinShow to check the returned outcome (or wrap
the call and rethrow if the helper indicates failure) before logging the success
message or returning result so failures cause the webhook to return an error
response instead of reporting success.

Addresses review comments from PR seerr-team#2648:

OpenAPI spec (seerr-api.yml):
- Replaced security: [] (which incorrectly advertised the endpoint as
  public) with proper webhookApiKeyHeader and webhookApiKeyQuery
  security schemes so Swagger/generated clients reflect the actual
  API key requirement.
- Removed the redundant apiKey query parameter definition since it's
  now modeled via the security scheme.
- Added missing 500 response documenting single-item processing failures.

CSRF exemption (server/index.ts):
- Wrapped the global csurf middleware to skip /api/v1/webhook/ paths.
  Without this, Jellyfin cannot POST to the webhook when CSRF protection
  is enabled because it has no way to provide the CSRF token.

Not addressed (intentionally):
- Error propagation from processJellyfinMovie/processJellyfinShow:
  These methods match the existing scanner pattern where errors are logged
  and swallowed. Changing their signature for the webhook path would
  diverge from how the rest of the scanner works. (The value added by
  this is also dubious)
- Batch coalescing by effectiveId: The existing collapse queue already
  handles rapid-fire webhooks from Jellyfin (one per episode). Within a
  single batched request this is a marginal optimization that adds
  significant complexity and isn't how the codebase handles things
  elsewhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 7915-7946: The OpenAPI operation leaves requestBody optional while
the handler rejects missing bodies—update the operation's requestBody to be
required by adding requestBody: required: true and keep the existing content
blocks (application/json and text/plain) so generated clients/validators will
enforce a non-empty body; locate the requestBody object shown (properties
ItemId, ItemIds, NotificationType) and add the required: true flag directly on
that requestBody node.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 197becc5-d230-40d2-aef4-fdc2483b6440

📥 Commits

Reviewing files that changed from the base of the PR and between 2e3d883 and a232535.

📒 Files selected for processing (2)
  • seerr-api.yml
  • server/index.ts

Comment on lines +7915 to +7946
requestBody:
content:
application/json:
schema:
type: object
properties:
ItemId:
type: string
description: Jellyfin item ID to process
ItemIds:
type: array
items:
type: string
description: Batch of Jellyfin item IDs to process
NotificationType:
type: string
description: Jellyfin notification event type
text/plain:
schema:
type: string
description: JSON-encoded body sent as text/plain
responses:
'200':
description: Item processed successfully
'207':
description: Batch processed with partial errors
'400':
description: Missing or invalid request body
'401':
description: Invalid API key
'500':
description: Internal server error - item processing failed
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mark the webhook body as required in the contract.

The PR behavior says missing request bodies return 400, but this operation still leaves requestBody optional. That means generated clients and validators will treat an empty POST as valid even though the handler rejects it.

📝 Proposed fix
       requestBody:
+        required: true
         content:
           application/json:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
requestBody:
content:
application/json:
schema:
type: object
properties:
ItemId:
type: string
description: Jellyfin item ID to process
ItemIds:
type: array
items:
type: string
description: Batch of Jellyfin item IDs to process
NotificationType:
type: string
description: Jellyfin notification event type
text/plain:
schema:
type: string
description: JSON-encoded body sent as text/plain
responses:
'200':
description: Item processed successfully
'207':
description: Batch processed with partial errors
'400':
description: Missing or invalid request body
'401':
description: Invalid API key
'500':
description: Internal server error - item processing failed
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ItemId:
type: string
description: Jellyfin item ID to process
ItemIds:
type: array
items:
type: string
description: Batch of Jellyfin item IDs to process
NotificationType:
type: string
description: Jellyfin notification event type
text/plain:
schema:
type: string
description: JSON-encoded body sent as text/plain
responses:
'200':
description: Item processed successfully
'207':
description: Batch processed with partial errors
'400':
description: Missing or invalid request body
'401':
description: Invalid API key
'500':
description: Internal server error - item processing failed
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 7915 - 7946, The OpenAPI operation leaves
requestBody optional while the handler rejects missing bodies—update the
operation's requestBody to be required by adding requestBody: required: true and
keep the existing content blocks (application/json and text/plain) so generated
clients/validators will enforce a non-empty body; locate the requestBody object
shown (properties ItemId, ItemIds, NotificationType) and add the required: true
flag directly on that requestBody node.

const uniqueIds = [...new Set(itemIds)];

logger.info(
`Jellyfin webhook: ${uniqueIds.length} item(s) [${notificationType ?? 'manual'}]`,

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.
Log entry depends on a
user-provided value
.
@gauthier-th
Copy link
Member

Closing this as it is nonsense since you can already trigger the "Recently Added Scan" using the Seerr REST API.

Example:
curl -H "X-Api-Key: XXX" -X POST http://hostname:5055/api/v1/settings/jobs/jellyfin-recently-added-scan/run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants