diff --git a/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs b/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs
index dc9e40a2b..4e232ccd1 100644
--- a/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs
+++ b/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs
@@ -81,6 +81,8 @@ public async Task SendNotificationAsync(string trigger, object data, string webh
}
#pragma warning restore CA1031
+ // Discord handled; do not fall through to the generic sender (would double-post).
+ return;
}
// NTFY-specific handling (https://docs.ntfy.sh/publish/)
@@ -139,6 +141,8 @@ public async Task SendNotificationAsync(string trigger, object data, string webh
_logger.LogError(ex, "Invalid operation while sending NTFY notification to {WebhookUrl}", LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment()));
}
+ // NTFY handled; do not fall through to the generic sender (would double-post).
+ return;
}
// Pushover (https://pushover.net/api)
diff --git a/tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs b/tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs
new file mode 100644
index 000000000..cf4695b79
--- /dev/null
+++ b/tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs
@@ -0,0 +1,111 @@
+/*
+ * Listenarr - Audiobook Management System
+ * Copyright (C) 2024-2026 Listenarr Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+using System.Net;
+
+namespace Listenarr.Tests.Features.Infrastructure.Notifications.Delivery
+{
+ // Regression tests: the Discord and NTFY handlers must not fall through to the
+ // generic webhook sender after handling the notification. The fall-through posted a
+ // second copy of every Discord/NTFY notification; for Discord the duplicate used a
+ // payload Discord rejects with 400 {"embeds": ["0"]} whenever the embed contained a
+ // relative thumbnail URL, making delivery look broken even though the first send
+ // had succeeded.
+ public partial class NotificationServiceTests
+ {
+ private static (NotificationService Service, Func GetRequestCount) CreateServiceWithCountingHandler()
+ {
+ var requestCount = 0;
+ var mockHandler = new Mock();
+ mockHandler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny()
+ )
+ .Callback((_, _) => Interlocked.Increment(ref requestCount))
+ .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK));
+
+ var httpClient = new HttpClient(mockHandler.Object);
+
+ var mockConfigService = new Mock();
+ mockConfigService
+ .Setup(x => x.GetStartupConfigAsync())
+ .ReturnsAsync(new StartupConfig { UrlBase = "https://listenarr.example.com" });
+
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ var provider = services.BuildServiceProvider();
+ var payloadBuilder = provider.GetRequiredService();
+
+ var service = new NotificationService(
+ httpClient,
+ Mock.Of>(),
+ mockConfigService.Object,
+ payloadBuilder,
+ Mock.Of()
+ );
+
+ return (service, () => requestCount);
+ }
+
+ [Fact]
+ public async Task SendNotificationAsync_DiscordWebhook_PostsExactlyOnce()
+ {
+ var (service, getRequestCount) = CreateServiceWithCountingHandler();
+ var trigger = "book-added";
+ var data = new
+ {
+ id = 1,
+ title = "Test Book",
+ authors = new[] { "Jane Doe" },
+ asin = "B00TEST"
+ };
+
+ await service.SendNotificationAsync(
+ trigger,
+ data,
+ "https://discord.com/api/webhooks/123456/token",
+ new List { trigger });
+
+ Assert.Equal(1, getRequestCount());
+ }
+
+ [Fact]
+ public async Task SendNotificationAsync_NtfyWebhook_PostsExactlyOnce()
+ {
+ var (service, getRequestCount) = CreateServiceWithCountingHandler();
+ var trigger = "book-added";
+ var data = new
+ {
+ id = 1,
+ title = "Test Book",
+ authors = new[] { "Jane Doe" },
+ asin = "B00TEST"
+ };
+
+ await service.SendNotificationAsync(
+ trigger,
+ data,
+ "https://ntfy.sh/listenarr-test",
+ new List { trigger });
+
+ Assert.Equal(1, getRequestCount());
+ }
+ }
+}