fix: webhook replay cache atomicity and instanceName precision
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m25s
CI / Tests & coverage (push) Failing after 1m30s
Licence Check / Licence compatibility and copyright header verification (pull_request) Successful in 57s
CI / Security audit (pull_request) Successful in 1m21s
CI / Tests & coverage (pull_request) Failing after 1m36s

This commit is contained in:
2026-05-20 20:46:35 +01:00
parent 1f10414498
commit 5ad525a760
+21 -21
View File
@@ -43,9 +43,11 @@ function pruneReplayCache() {
}
}
// Prune the replay cache once per minute
setInterval(pruneReplayCache, 60 * 1000).unref();
function isReplay(eventType, instanceName, eventDate) {
if (!eventDate) return false;
pruneReplayCache();
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
if (recentEvents.has(key)) return true;
recentEvents.set(key, Date.now());
@@ -237,24 +239,23 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
const { eventType, instanceName, eventDate } = validation;
if (isReplay(eventType, instanceName, eventDate)) {
const sonarrInstances = getSonarrInstances();
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
// Note: instanceName from webhook is often generic (e.g., "Sonarr"), not the configured name
// Update metrics for all Sonarr instances since we can't reliably match
const sonarrInstances = getSonarrInstances();
if (sonarrInstances.length > 0) {
for (const inst of sonarrInstances) {
cache.updateWebhookMetrics(inst.url);
}
logToFile(`[Webhook] Updated metrics for ${sonarrInstances.length} Sonarr instance(s)`);
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.url})`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
@@ -290,24 +291,23 @@ router.post('/radarr', webhookLimiter, (req, res) => {
const { eventType, instanceName, eventDate } = validation;
if (isReplay(eventType, instanceName, eventDate)) {
const radarrInstances = getRadarrInstances();
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
return res.status(200).json({ received: true, duplicate: true });
}
try {
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
// Note: instanceName from webhook is often generic (e.g., "Radarr"), not the configured name
// Update metrics for all Radarr instances since we can't reliably match
const radarrInstances = getRadarrInstances();
if (radarrInstances.length > 0) {
for (const inst of radarrInstances) {
cache.updateWebhookMetrics(inst.url);
}
logToFile(`[Webhook] Updated metrics for ${radarrInstances.length} Radarr instance(s)`);
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.url})`);
}
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)