feat(webhooks): add Jellyfin webhook endpoint#2648
feat(webhooks): add Jellyfin webhook endpoint#2648alchemyyy wants to merge 2 commits intoseerr-team:developfrom
Conversation
📝 WalkthroughWalkthroughAdds 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
server/routes/webhooks/jellyfin.ts (1)
75-141: BatchedItemIdscan 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 inserver/lib/scanners/jellyfin/index.tsnever sees overlap and the same series can be fully rescanned repeatedly. Consider collapsing the batch byeffectiveId, 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
📒 Files selected for processing (4)
seerr-api.ymlserver/lib/scanners/jellyfin/index.tsserver/routes/index.tsserver/routes/webhooks/jellyfin.ts
| 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; |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
seerr-api.ymlserver/index.ts
| 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 |
There was a problem hiding this comment.
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.
| 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.
|
Closing this as it is nonsense since you can already trigger the "Recently Added Scan" using the Seerr REST API. Example: |
Description
Adds a webhook endpoint at
/webhook/jellyfinthat receives push notifications from the Jellyfin Webhook Plugin, giving event-driven library sync instead of waiting for the next scheduled scan.How it works
ItemAdded,ItemUpdated).X-API-Keyheader). It also handles the plugin's defaulttext/plainContent-Type by parsing it as JSON locally.ItemIdorItemIds), then filters byNotificationType— onlyItemAddedandItemUpdatedare processed; everything else (playback, auth, tasks) is acknowledged and ignored.JellyfinScannerinternals (processJellyfinMovie/processJellyfinShow) — the same code path as scheduled scans — so availability status updates identically.How Has This Been Tested?
ItemAddedandItemUpdatedevents to/webhook/jellyfin?apiKey=<key>Screenshots / Logs (if applicable)
Checklist:
pnpm buildpnpm i18n:extractSummary by CodeRabbit
New Features
Other Changes