fix(webhooks): redesign replay key with content identifiers, log instance fallback (closes #62)

This commit is contained in:
2026-05-28 15:30:08 +01:00
parent b4a9d7187b
commit 593ad79670
2 changed files with 33 additions and 8 deletions
+27 -8
View File
@@ -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 });
}