diff --git a/CHANGELOG.md b/CHANGELOG.md index 775c57d..1a28d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.32] - YYYY-MM-DD + +### Fixed + +- **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62). + ## [1.7.31] - 2026-05-28 ### Fixed diff --git a/server/routes/webhook.js b/server/routes/webhook.js index f21dedc..e759c53 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -106,9 +106,14 @@ function pruneReplayCache() { // Prune the replay cache once per minute setInterval(pruneReplayCache, 60 * 1000).unref(); -function isReplay(eventType, instanceName, eventDate) { +function isReplay(eventType, instanceName, eventDate, contentId) { if (!eventDate) return false; - const key = `${eventType}:${instanceName || ''}:${eventDate}`; + // Content-aware replay key: incorporates downloadId / series.id / movie.id when + // available so that distinct events sharing the same `date` (e.g. multiple + // Grab events for episodes in a season pack fired in the same second) do not + // falsely collide. Falls back to the prior shape when contentId is absent + // (e.g. Test events) so existing behaviour is preserved. + const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${eventDate}`; if (recentEvents.has(key)) return true; recentEvents.set(key, Date.now()); return false; @@ -480,11 +485,18 @@ router.post('/sonarr', webhookLimiter, (req, res) => { const { eventType, instanceName, eventDate } = validation; const sonarrInstances = getSonarrInstances(); - const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0]; + const matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName); + const inst = matchedInst || sonarrInstances[0]; + if (!matchedInst && instanceName) { + logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`); + } const resolvedInstanceName = inst ? inst.name : instanceName; - if (isReplay(eventType, resolvedInstanceName, eventDate)) { - logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`); + // Content-aware replay key components (Issue #62) + const contentId = req.body.downloadId || req.body.series?.id || null; + + if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) { + logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`); return res.status(200).json({ received: true, duplicate: true }); } @@ -634,11 +646,18 @@ router.post('/radarr', webhookLimiter, (req, res) => { const { eventType, instanceName, eventDate } = validation; const radarrInstances = getRadarrInstances(); - const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0]; + const matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName); + const inst = matchedInst || radarrInstances[0]; + if (!matchedInst && instanceName) { + logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`); + } const resolvedInstanceName = inst ? inst.name : instanceName; - if (isReplay(eventType, resolvedInstanceName, eventDate)) { - logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`); + // Content-aware replay key components (Issue #62) + const contentId = req.body.downloadId || req.body.movie?.id || null; + + if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) { + logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`); return res.status(200).json({ received: true, duplicate: true }); }