From 01a428ac715f77718f8c5fe7a361611980b5f4fb Mon Sep 17 00:00:00 2001 From: dny238 Date: Tue, 15 Sep 2026 17:00:57 -0600 Subject: [PATCH 1/3] Add NotificationTriggers catalog + endpoint; fire book-wanted on monitored add - New Listenarr.Domain.Notifications.NotificationTriggers: central catalog of the full book lifecycle (acquisition + library-management) trigger names, with display metadata + default-enabled set (single source of truth vs. magic strings). - GET /api/v1/notifications/triggers serves the catalog (frontend will render from it). - Fire book-wanted (in addition to book-added) when a monitored book is added, on both the service and API add paths. First increment of the comprehensive lifecycle-notification feature. Co-Authored-By: Claude Opus 4.8 --- .../Library/LibraryAddWorkflow.PostCommit.cs | 17 ++++- .../Notifications/NotificationsController.cs | 20 ++++++ .../Catalog/LibraryAddService.PostCommit.cs | 17 ++++- .../Notifications/NotificationTriggers.cs | 70 +++++++++++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 listenarr.domain/Notifications/NotificationTriggers.cs diff --git a/listenarr.api/Features/Library/LibraryAddWorkflow.PostCommit.cs b/listenarr.api/Features/Library/LibraryAddWorkflow.PostCommit.cs index 781b849a2..12b4262cd 100644 --- a/listenarr.api/Features/Library/LibraryAddWorkflow.PostCommit.cs +++ b/listenarr.api/Features/Library/LibraryAddWorkflow.PostCommit.cs @@ -1,3 +1,5 @@ +using Listenarr.Domain.Notifications; + namespace Listenarr.Api.Features.Library { public sealed partial class LibraryAddWorkflow @@ -24,13 +26,24 @@ private async Task SendAddedNotificationAsync(Audiobook audiobook) asin = audiobook.Asin, publisher = audiobook.Publisher, year = audiobook.PublishYear, - imageUrl = audiobook.ImageUrl + imageUrl = audiobook.ImageUrl, + monitored = audiobook.Monitored }; await _notificationService.SendNotificationAsync( - "book-added", + NotificationTriggers.BookAdded, data, settings.WebhookUrl, settings.EnabledNotificationTriggers); + + // A monitored book is "wanted"; fire the dedicated trigger too. + if (audiobook.Monitored) + { + await _notificationService.SendNotificationAsync( + NotificationTriggers.BookWanted, + data, + settings.WebhookUrl, + settings.EnabledNotificationTriggers); + } } catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException diff --git a/listenarr.api/Features/Notifications/NotificationsController.cs b/listenarr.api/Features/Notifications/NotificationsController.cs index e4ef7613e..d160f1361 100644 --- a/listenarr.api/Features/Notifications/NotificationsController.cs +++ b/listenarr.api/Features/Notifications/NotificationsController.cs @@ -17,6 +17,7 @@ */ using Listenarr.Api.Attributes; +using Listenarr.Domain.Notifications; using Microsoft.AspNetCore.Mvc; namespace Listenarr.Api.Features.Notifications @@ -41,6 +42,25 @@ public NotificationsController( _notificationService = notificationService; } + /// + /// The catalog of notification triggers spanning the book lifecycle (acquisition + + /// library management). Served so clients render the trigger list from a single source + /// of truth rather than hardcoding it. + /// + [HttpGet("triggers")] + public ActionResult GetTriggers() + { + var triggers = NotificationTriggers.Catalog + .OrderBy(trigger => trigger.Order) + .Select(trigger => new + { + id = trigger.Id, + name = trigger.DisplayName, + description = trigger.Description, + }); + return Ok(triggers); + } + /// /// Send a test notification to the configured webhook URL. /// diff --git a/listenarr.application/Audiobooks/Catalog/LibraryAddService.PostCommit.cs b/listenarr.application/Audiobooks/Catalog/LibraryAddService.PostCommit.cs index abbf4a94c..04cda7e2e 100644 --- a/listenarr.application/Audiobooks/Catalog/LibraryAddService.PostCommit.cs +++ b/listenarr.application/Audiobooks/Catalog/LibraryAddService.PostCommit.cs @@ -1,3 +1,4 @@ +using Listenarr.Domain.Notifications; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Audiobooks.Catalog; @@ -84,14 +85,26 @@ private async Task SendAddedNotificationAsync(Audiobook audiobook) asin = audiobook.Asin, publisher = audiobook.Publisher, year = audiobook.PublishYear, - imageUrl = audiobook.ImageUrl + imageUrl = audiobook.ImageUrl, + monitored = audiobook.Monitored }; await _notificationService.SendNotificationAsync( - "book-added", + NotificationTriggers.BookAdded, data, settings.WebhookUrl, settings.EnabledNotificationTriggers); + + // A monitored book is "wanted" (eligible for acquisition). Fire the dedicated trigger so + // integrations can subscribe to "wanted" specifically without inspecting the monitored flag. + if (audiobook.Monitored) + { + await _notificationService.SendNotificationAsync( + NotificationTriggers.BookWanted, + data, + settings.WebhookUrl, + settings.EnabledNotificationTriggers); + } } private static History CreateHistoryEntry( diff --git a/listenarr.domain/Notifications/NotificationTriggers.cs b/listenarr.domain/Notifications/NotificationTriggers.cs new file mode 100644 index 000000000..f84e6dcf7 --- /dev/null +++ b/listenarr.domain/Notifications/NotificationTriggers.cs @@ -0,0 +1,70 @@ +/* + * 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. + */ +namespace Listenarr.Domain.Notifications +{ + /// + /// Canonical notification trigger names covering the book-acquisition lifecycle, plus a catalog + /// (display name, description, ordering) so the backend is the single source of truth. The + /// settings UI and any consumer read the catalog rather than hardcoding trigger lists. + /// + /// A trigger corresponds to a lifecycle transition that is already recorded in the activity + /// history (see ); the notification is fired at the + /// same point so external integrations (Discord, NTFY, custom scripts, …) can subscribe to any + /// stage without Listenarr knowing what they do with it. + /// + public static class NotificationTriggers + { + public const string BookWanted = "book-wanted"; + public const string BookGrabbed = "book-grabbed"; + public const string BookDownloading = "book-downloading"; + public const string BookDownloadCompleted = "book-download-completed"; + public const string BookImported = "book-imported"; + public const string BookAvailable = "book-available"; + public const string BookCompleted = "book-completed"; + public const string BookDownloadFailed = "book-download-failed"; + public const string BookImportFailed = "book-import-failed"; + + /// Any add to the library (monitored or not). Retained for back-compat. + public const string BookAdded = "book-added"; + + // Library-management lifecycle (beyond acquisition). + public const string BookUpgraded = "book-upgraded"; + public const string BookDeleted = "book-deleted"; + public const string BookRenamed = "book-renamed"; + + /// One catalog entry: the trigger id, a human label, a description, and lifecycle order. + public sealed record Definition(string Id, string DisplayName, string Description, int Order); + + /// The full trigger catalog, ordered by lifecycle stage. + public static readonly IReadOnlyList Catalog = new List + { + new(BookWanted, "Book Wanted", "A monitored book is wanted and eligible for acquisition.", 10), + new(BookGrabbed, "Book Grabbed", "A release was accepted and sent to a download client.", 20), + new(BookDownloading, "Downloading", "A download has started.", 30), + new(BookDownloadCompleted, "Download Completed", "The download client finished downloading.", 40), + new(BookImported, "Imported", "The downloaded file was imported into the library.", 50), + new(BookAvailable, "Available", "The book's files are registered and available in the library.", 60), + new(BookCompleted, "Completed", "Post-import processing (move/organize) finished.", 70), + new(BookDownloadFailed, "Download Failed", "A download failed.", 80), + new(BookImportFailed, "Import Failed", "An import failed.", 90), + new(BookAdded, "Book Added", "A book was added to the library.", 100), + new(BookUpgraded, "Book Upgraded", "An existing book's file was replaced by a higher-quality one.", 110), + new(BookRenamed, "Book Renamed", "A book's files were renamed.", 120), + new(BookDeleted, "Book Deleted", "A book or its files were removed from the library.", 130), + }; + + /// Trigger ids enabled by default on a fresh install (users may narrow this). + public static readonly IReadOnlyList DefaultEnabled = + Catalog.OrderBy(d => d.Order).Select(d => d.Id).ToList(); + + public static bool IsKnown(string trigger) => + Catalog.Any(d => string.Equals(d.Id, trigger, System.StringComparison.Ordinal)); + } +} From 1e2e684eca70f67a6921980a5a22afe6792e1754 Mon Sep 17 00:00:00 2001 From: dny238 Date: Tue, 15 Sep 2026 22:50:45 -0600 Subject: [PATCH 2/3] feat(notifications): fire lifecycle triggers across the book-acquisition pipeline Expand notifications from the four historical triggers to a backend-served catalog covering the whole acquisition + library lifecycle, so integrations (Discord/NTFY/Slack/custom scripts) can subscribe to any stage. New trigger fire points (all gated by the global enabled-trigger set): - book-wanted / book-added LibraryAddService + LibraryAddWorkflow (add + monitored) - book-grabbed / book-downloading DownloadService (at client submission) - book-download-completed / book-download-failed DownloadMonitorService - book-imported ImportFinalizationService (post-commit) - book-import-failed DownloadProcessingJobProcessor (terminal failure incl. retry exhaustion) - book-available ScanJobProcessor (existing; now uses the constant) - book-completed MoveJobProcessor (post-move/organize) - book-renamed RenameService (per successful rename) - book-deleted LibraryDeleteWorkflow (single-item delete) Foundation: - NotificationTriggers catalog (id/label/description/order) is the single source of truth; NotificationsController exposes GET /notifications/triggers. - IBookLifecycleNotifier facade resolves settings and dispatches to the configured webhook + enabled-trigger gate (best-effort; swallows non-fatal errors). - ApplicationSettings default now derives from NotificationTriggers.DefaultEnabled, intentionally the historical four so upgrades emit no new notification types unless the user opts in. - Discord content builder is data-driven, giving every trigger a friendly message instead of a raw "[trigger]" fallback. - book-upgraded reserved (documented) but not catalogued: no reliable upgrade-completion signal exists in the import path yet. Frontend: NotificationsTab consumes GET /notifications/triggers (single source of truth) with a seeded fallback; trigger list/order/labels/icons follow the catalog. Tests: catalog integrity, controller endpoint, notifier dispatch/error-swallowing, and per-trigger Discord content phrasing. Backend build + architecture gates green; fe type-check green. Co-Authored-By: Claude Opus 4.8 --- fe/src/services/api.ts | 9 ++ fe/src/views/settings/NotificationsTab.vue | 44 +++++++-- .../Features/Library/LibraryDeleteWorkflow.cs | 15 +++ .../Audiobooks/Renaming/RenameService.cs | 18 +++- .../Downloads/Submission/DownloadService.cs | 6 +- .../Contracts/IBookLifecycleNotifier.cs | 23 +++++ .../NotificationPayloadBuilder.Attachments.cs | 58 +++++------- .../Configuration/ApplicationSettings.cs | 3 +- .../Notifications/NotificationTriggers.cs | 26 +++++- .../NotificationRegistrationExtensions.cs | 1 + .../Import/ImportFinalizationService.cs | 11 ++- .../Monitoring/DownloadMonitorService.cs | 18 ++++ .../DownloadProcessingJobProcessor.History.cs | 29 ++++++ .../DownloadProcessingJobProcessor.cs | 21 +++-- .../Moving/MoveJobProcessor.Completion.cs | 12 +++ .../ScanJobProcessor.Notifications.cs | 3 +- .../Delivery/BookLifecycleNotifier.cs | 50 ++++++++++ .../NotificationsControllerTests.cs | 71 +++++++++++++++ .../NotificationPayloadContentTests.cs | 77 ++++++++++++++++ .../NotificationTriggersTests.cs | 81 +++++++++++++++++ .../Delivery/BookLifecycleNotifierTests.cs | 91 +++++++++++++++++++ 21 files changed, 606 insertions(+), 61 deletions(-) create mode 100644 listenarr.application/Notifications/Contracts/IBookLifecycleNotifier.cs create mode 100644 listenarr.infrastructure/Notifications/Delivery/BookLifecycleNotifier.cs create mode 100644 tests/Features/Api/Notifications/NotificationsControllerTests.cs create mode 100644 tests/Features/Application/Notifications/Payloads/NotificationPayloadContentTests.cs create mode 100644 tests/Features/Domain/Notifications/NotificationTriggersTests.cs create mode 100644 tests/Features/Infrastructure/Notifications/Delivery/BookLifecycleNotifierTests.cs diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index fc79b6ea9..48f5f66ec 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -860,6 +860,15 @@ class ApiService { }) } + // Backend-served catalog of notification lifecycle triggers (single source of truth). + async getNotificationTriggers(): Promise< + Array<{ id: string; name: string; description: string }> + > { + return this.request>( + '/notifications/triggers', + ) + } + // Application Settings async getApplicationSettings(): Promise { return this.request('/configuration/settings') diff --git a/fe/src/views/settings/NotificationsTab.vue b/fe/src/views/settings/NotificationsTab.vue index 7101f04c0..c6e018ca9 100644 --- a/fe/src/views/settings/NotificationsTab.vue +++ b/fe/src/views/settings/NotificationsTab.vue @@ -319,7 +319,7 @@
>([ + { id: 'book-added', name: 'Book Added', description: '' }, + { id: 'book-downloading', name: 'Download Started', description: '' }, + { id: 'book-available', name: 'Available', description: '' }, + { id: 'book-completed', name: 'Completed', description: '' }, +]) + // Computed const isWebhookFormValid = computed(() => { if (!webhookForm.name.trim() || webhookForm.type === '') return false @@ -517,10 +526,18 @@ function generateUUID(): string { const getTriggerIcon = (trigger: string) => { const iconMap: Record = { - 'book-added': PhPlus, + 'book-wanted': PhBell, + 'book-grabbed': PhDownloadSimple, 'book-downloading': PhDownloadSimple, + 'book-download-completed': PhCheckCircle, + 'book-imported': PhCircleWavyCheck, 'book-available': PhCheckCircle, 'book-completed': PhCircleWavyCheck, + 'book-download-failed': PhXCircle, + 'book-import-failed': PhXCircle, + 'book-added': PhPlus, + 'book-renamed': PhPencil, + 'book-deleted': PhTrash, } return iconMap[trigger] || PhBell } @@ -549,15 +566,18 @@ const getTriggerClass = (trigger: string): string => { return classMap[trigger] || '' } -// Return triggers in a consistent display order -const orderedTriggerList = ['book-added', 'book-downloading', 'book-available', 'book-completed'] +// Display order follows the backend catalog (falls back to the seeded list before it loads). +const orderedTriggerList = computed(() => availableTriggers.value.map((t) => t.id)) const orderedTriggers = (triggers: string[] | undefined) => { if (!triggers || triggers.length === 0) return [] - return orderedTriggerList.filter((t) => triggers.includes(t)) + return orderedTriggerList.value.filter((t) => triggers.includes(t)) } const formatTriggerName = (trigger: string): string => { + const fromCatalog = availableTriggers.value.find((t) => t.id === trigger)?.name + if (fromCatalog) return fromCatalog + // Fallback labels for any id not present in the catalog. const nameMap: Record = { 'book-added': 'Book Added', 'book-downloading': 'Download Started', @@ -1015,10 +1035,22 @@ const persistWebhooks = async () => { } // Initialize webhooks from settings -onMounted(() => { +onMounted(async () => { if (props.settings?.webhooks) { webhooks.value = props.settings.webhooks } + try { + const triggers = await apiService.getNotificationTriggers() + if (Array.isArray(triggers) && triggers.length > 0) { + availableTriggers.value = triggers + } + } catch (err) { + // Non-fatal: keep the seeded fallback list so the form remains usable offline. + errorTracking.captureException(err, { + component: 'NotificationsTab', + operation: 'getNotificationTriggers', + }) + } }) // Expose openWebhookForm for parent component diff --git a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs index ec34bc4b6..653ebe3ec 100644 --- a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs @@ -20,6 +20,7 @@ using Listenarr.Application.Common; using Listenarr.Application.Common.Exceptions; using Listenarr.Domain.Common; +using Listenarr.Domain.Notifications; using Microsoft.AspNetCore.Mvc; namespace Listenarr.Api.Features.Library @@ -40,6 +41,7 @@ public sealed partial class LibraryDeleteWorkflow private readonly IRootFolderService _rootFolderService; private readonly IRootFolderStorageHealthResolver _storageHealthResolver; private readonly IAudiobookFileIdentityReconciler _fileIdentityReconciler; + private readonly IBookLifecycleNotifier _lifecycleNotifier; private readonly ILogger _logger; public LibraryDeleteWorkflow( @@ -57,6 +59,7 @@ public LibraryDeleteWorkflow( IRootFolderService rootFolderService, IRootFolderStorageHealthResolver storageHealthResolver, IAudiobookFileIdentityReconciler fileIdentityReconciler, + IBookLifecycleNotifier lifecycleNotifier, ILogger logger) { _deletionCommitService = deletionCommitService ?? throw new ArgumentNullException(nameof(deletionCommitService)); @@ -77,6 +80,8 @@ public LibraryDeleteWorkflow( ?? throw new ArgumentNullException(nameof(storageHealthResolver)); _fileIdentityReconciler = fileIdentityReconciler ?? throw new ArgumentNullException(nameof(fileIdentityReconciler)); + _lifecycleNotifier = lifecycleNotifier + ?? throw new ArgumentNullException(nameof(lifecycleNotifier)); _logger = logger; } @@ -316,6 +321,16 @@ await _deletionIntentStore.MarkCompletedAsync( } await DeleteCachedImageAsync(audiobook); + await _lifecycleNotifier.NotifyAsync( + NotificationTriggers.BookDeleted, + new + { + id = audiobook.Id, + title = audiobook.Title, + deletedFiles = deleteFiles, + deletedFolder = deleteFolder, + }, + cancellationToken); var message = filesystemResult?.BuildDeleteMessage() ?? "Audiobook deleted successfully."; return new OkObjectResult(new { diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.cs b/listenarr.application/Audiobooks/Renaming/RenameService.cs index 0ae1bae47..74c593516 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ using Listenarr.Application.Common; +using Listenarr.Domain.Notifications; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Audiobooks.Renaming @@ -40,6 +41,7 @@ public partial class RenameService : IRenameService private readonly IMoveQueueService _moveQueueService; private readonly ILibraryDirectoryOwnershipStore _directoryOwnershipStore; private readonly IFileRenameCommitStore _fileRenameCommitStore; + private readonly IBookLifecycleNotifier? _lifecycleNotifier; public RenameService( IConfigurationService configService, @@ -57,7 +59,8 @@ public RenameService( ILibraryDirectoryOwnershipStore directoryOwnershipStore, IFileRenameCommitStore fileRenameCommitStore, IRootFolderService? rootFolderService = null, - IHistoryRepository? historyRepository = null) + IHistoryRepository? historyRepository = null, + IBookLifecycleNotifier? lifecycleNotifier = null) { _configService = configService; _fileNamingService = fileNamingService; @@ -75,6 +78,7 @@ public RenameService( _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _directoryOwnershipStore = directoryOwnershipStore ?? throw new ArgumentNullException(nameof(directoryOwnershipStore)); _fileRenameCommitStore = fileRenameCommitStore ?? throw new ArgumentNullException(nameof(fileRenameCommitStore)); + _lifecycleNotifier = lifecycleNotifier; } public async Task> PreviewRenameAsync(int[] audiobookIds, CancellationToken ct = default) @@ -369,6 +373,18 @@ await CommitRollbackStateAsync( } await AddHistoryAsync(audiobook, result); + + if (result.Success && _lifecycleNotifier != null) + { + await _lifecycleNotifier.NotifyAsync( + NotificationTriggers.BookRenamed, + new + { + id = audiobook.Id, + title = audiobook.Title, + }, + ct); + } } return result; diff --git a/listenarr.application/Downloads/Submission/DownloadService.cs b/listenarr.application/Downloads/Submission/DownloadService.cs index 5746081d0..9e38e7b9c 100644 --- a/listenarr.application/Downloads/Submission/DownloadService.cs +++ b/listenarr.application/Downloads/Submission/DownloadService.cs @@ -17,6 +17,7 @@ */ using Listenarr.Application.Common; +using Listenarr.Domain.Notifications; using Microsoft.Extensions.Logging; namespace Listenarr.Application.Downloads.Submission @@ -391,8 +392,9 @@ await downloadHistoryService.RecordGrabbedAsync( downloadId, ToSearchResult(candidate, prepared), downloadClient); - - await notificationService.SendNotificationAsync("book-downloading", notificationData, settings.WebhookUrl, settings.EnabledNotificationTriggers); + // Accepted by the client (grabbed) and now downloading — both fire at submission. + await notificationService.SendNotificationAsync(NotificationTriggers.BookGrabbed, notificationData, settings.WebhookUrl, settings.EnabledNotificationTriggers); + await notificationService.SendNotificationAsync(NotificationTriggers.BookDownloading, notificationData, settings.WebhookUrl, settings.EnabledNotificationTriggers); // Trigger an immediate realtime queue update so the UI shows the new download right away // Add a small delay to allow the download client to process and index the new download diff --git a/listenarr.application/Notifications/Contracts/IBookLifecycleNotifier.cs b/listenarr.application/Notifications/Contracts/IBookLifecycleNotifier.cs new file mode 100644 index 000000000..e9ce78aed --- /dev/null +++ b/listenarr.application/Notifications/Contracts/IBookLifecycleNotifier.cs @@ -0,0 +1,23 @@ +/* + * 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. + */ +namespace Listenarr.Application.Notifications.Contracts +{ + /// + /// Fires a book-lifecycle notification for a given trigger (see + /// ). Resolves the current notification + /// settings itself and dispatches through , so callers at + /// lifecycle transition points only supply the trigger and payload. Best-effort: it never + /// throws into the calling flow. + /// + public interface IBookLifecycleNotifier + { + Task NotifyAsync(string trigger, object payload, CancellationToken cancellationToken = default); + } +} diff --git a/listenarr.application/Notifications/Payloads/NotificationPayloadBuilder.Attachments.cs b/listenarr.application/Notifications/Payloads/NotificationPayloadBuilder.Attachments.cs index 101dacde7..ddda0f918 100644 --- a/listenarr.application/Notifications/Payloads/NotificationPayloadBuilder.Attachments.cs +++ b/listenarr.application/Notifications/Payloads/NotificationPayloadBuilder.Attachments.cs @@ -259,51 +259,41 @@ static string Truncate(string? value, int max) return scheme + "://" + host; } - private static string BuildDiscordContent(string trigger, string title, string author) - { - if (string.Equals(trigger, "book-added", StringComparison.OrdinalIgnoreCase)) - { - if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(author)) - { - return $"{title} by {author} has been added"; - } - - if (!string.IsNullOrWhiteSpace(title)) - { - return $"{title} has been added"; - } - - return "A new audiobook has been added"; - } - - if (string.Equals(trigger, "book-available", StringComparison.OrdinalIgnoreCase)) + // Human phrasing per lifecycle trigger: (verb phrase, generic-noun fallback). + // Keyed by canonical trigger id (see Listenarr.Domain.Notifications.NotificationTriggers). + private static readonly IReadOnlyDictionary TriggerContentPhrases = + new Dictionary(StringComparer.OrdinalIgnoreCase) { - if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(author)) - { - return $"{title} by {author} is now available"; - } - - if (!string.IsNullOrWhiteSpace(title)) - { - return $"{title} is now available"; - } - - return "An audiobook is now available"; - } + ["book-wanted"] = ("is wanted", "An audiobook is wanted"), + ["book-grabbed"] = ("was grabbed", "An audiobook was grabbed"), + ["book-downloading"] = ("is downloading", "An audiobook is downloading"), + ["book-download-completed"] = ("finished downloading", "An audiobook finished downloading"), + ["book-imported"] = ("was imported", "An audiobook was imported"), + ["book-available"] = ("is now available", "An audiobook is now available"), + ["book-completed"] = ("is complete", "An audiobook is complete"), + ["book-download-failed"] = ("failed to download", "An audiobook failed to download"), + ["book-import-failed"] = ("failed to import", "An audiobook failed to import"), + ["book-added"] = ("has been added", "A new audiobook has been added"), + ["book-upgraded"] = ("was upgraded", "An audiobook was upgraded"), + ["book-renamed"] = ("was renamed", "An audiobook was renamed"), + ["book-deleted"] = ("was deleted", "An audiobook was deleted"), + }; - if (string.Equals(trigger, "book-downloading", StringComparison.OrdinalIgnoreCase)) + private static string BuildDiscordContent(string trigger, string title, string author) + { + if (TriggerContentPhrases.TryGetValue(trigger, out var content)) { if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(author)) { - return $"{title} by {author} is downloading"; + return $"{title} by {author} {content.Phrase}"; } if (!string.IsNullOrWhiteSpace(title)) { - return $"{title} is downloading"; + return $"{title} {content.Phrase}"; } - return "An audiobook is downloading"; + return content.Generic; } if (!string.IsNullOrWhiteSpace(title)) diff --git a/listenarr.domain/Configuration/ApplicationSettings.cs b/listenarr.domain/Configuration/ApplicationSettings.cs index 61d65cb78..e33a1f4f3 100644 --- a/listenarr.domain/Configuration/ApplicationSettings.cs +++ b/listenarr.domain/Configuration/ApplicationSettings.cs @@ -121,7 +121,8 @@ public List ImportBlacklistExtensions /// /// List of enabled notification triggers (legacy). /// - public List EnabledNotificationTriggers { get; set; } = new() { "book-added", "book-downloading", "book-available", "book-completed" }; + public List EnabledNotificationTriggers { get; set; } = + Notifications.NotificationTriggers.DefaultEnabled.ToList(); /// /// Multiple webhooks configuration (new format). diff --git a/listenarr.domain/Notifications/NotificationTriggers.cs b/listenarr.domain/Notifications/NotificationTriggers.cs index f84e6dcf7..d1466be78 100644 --- a/listenarr.domain/Notifications/NotificationTriggers.cs +++ b/listenarr.domain/Notifications/NotificationTriggers.cs @@ -35,10 +35,18 @@ public static class NotificationTriggers public const string BookAdded = "book-added"; // Library-management lifecycle (beyond acquisition). - public const string BookUpgraded = "book-upgraded"; public const string BookDeleted = "book-deleted"; public const string BookRenamed = "book-renamed"; + /// + /// Reserved for a future "a better file replaced an existing one" event. Not in the + /// catalog yet because Listenarr's import path does not currently emit a distinct + /// upgrade-completion signal (a replacement looks identical to a first import), so + /// firing it reliably would require new pre-import state tracking. Kept as a stable id + /// so integrations and the catalog can adopt it without a breaking rename later. + /// + public const string BookUpgraded = "book-upgraded"; + /// One catalog entry: the trigger id, a human label, a description, and lifecycle order. public sealed record Definition(string Id, string DisplayName, string Description, int Order); @@ -55,14 +63,22 @@ public sealed record Definition(string Id, string DisplayName, string Descriptio new(BookDownloadFailed, "Download Failed", "A download failed.", 80), new(BookImportFailed, "Import Failed", "An import failed.", 90), new(BookAdded, "Book Added", "A book was added to the library.", 100), - new(BookUpgraded, "Book Upgraded", "An existing book's file was replaced by a higher-quality one.", 110), new(BookRenamed, "Book Renamed", "A book's files were renamed.", 120), new(BookDeleted, "Book Deleted", "A book or its files were removed from the library.", 130), }; - /// Trigger ids enabled by default on a fresh install (users may narrow this). - public static readonly IReadOnlyList DefaultEnabled = - Catalog.OrderBy(d => d.Order).Select(d => d.Id).ToList(); + /// + /// Trigger ids enabled by default on a fresh install. Intentionally the historical + /// back-compat set (the four that shipped before the lifecycle expansion) so upgrading + /// does not suddenly emit new notification types; every other trigger is opt-in via the UI. + /// + public static readonly IReadOnlyList DefaultEnabled = new List + { + BookAdded, + BookDownloading, + BookAvailable, + BookCompleted, + }; public static bool IsKnown(string trigger) => Catalog.Any(d => string.Equals(d.Id, trigger, System.StringComparison.Ordinal)); diff --git a/listenarr.infrastructure/DependencyInjection/Notifications/NotificationRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Notifications/NotificationRegistrationExtensions.cs index 72e8d712d..13c17fa0a 100644 --- a/listenarr.infrastructure/DependencyInjection/Notifications/NotificationRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Notifications/NotificationRegistrationExtensions.cs @@ -21,6 +21,7 @@ public static IServiceCollection AddNotificationAndRealtimeServices(this IServic ActivatorUtilities.CreateInstance(provider)); services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs b/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs index 55362f156..672987b1d 100644 --- a/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs +++ b/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs @@ -8,13 +8,15 @@ * (at your option) any later version. */ using System.Text.Json; +using Listenarr.Domain.Notifications; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Listenarr.Infrastructure.Downloads.Import { public sealed class ImportFinalizationService( - IDbContextFactory dbFactory) : IImportFinalizationService + IDbContextFactory dbFactory, + IBookLifecycleNotifier lifecycleNotifier) : IImportFinalizationService { public async Task FinalizeAsync( string jobId, @@ -94,6 +96,13 @@ public async Task FinalizeAsync( job.SetCheckpoint("ImportCommitted"); await db.SaveChangesAsync(ct); + + await lifecycleNotifier.NotifyAsync(NotificationTriggers.BookImported, new + { + id = audiobookId, + downloadId, + title, + }, ct); } } } diff --git a/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs b/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs index 6094abf3c..9b6a57e5c 100644 --- a/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs +++ b/listenarr.infrastructure/Downloads/Monitoring/DownloadMonitorService.cs @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +using Listenarr.Domain.Notifications; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -342,6 +343,14 @@ await historyRepository.AddAsync(new History }) }); await downloadProcessingJobService.EnqueueAsync(download); + + var notifier = scope.ServiceProvider.GetRequiredService(); + await notifier.NotifyAsync(NotificationTriggers.BookDownloadCompleted, new + { + id = download.AudiobookId, + downloadId = download.Id, + title = download.Title, + }); } catch (InvalidOperationException exception) { @@ -384,6 +393,15 @@ await downloadHistoryService.RecordDownloadFailedAsync( download.Title ?? "Unknown", errorMessage); + var notifier = scope.ServiceProvider.GetRequiredService(); + await notifier.NotifyAsync(NotificationTriggers.BookDownloadFailed, new + { + id = download.AudiobookId, + downloadId = download.Id, + title = download.Title, + error = errorMessage, + }); + if (!settings.FailedDownloadHandlingEnabled) { return; diff --git a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.History.cs b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.History.cs index 37f367564..bcd9759fe 100644 --- a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.History.cs +++ b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.History.cs @@ -9,6 +9,7 @@ */ using System.Text.Json; +using Listenarr.Domain.Notifications; namespace Listenarr.Infrastructure.Downloads.Processing { @@ -18,6 +19,7 @@ private static async Task ScheduleRetryAsync( DownloadProcessingJob job, IDownloadProcessingJobService jobService, IHistoryRepository historyRepository, + IBookLifecycleNotifier lifecycleNotifier, Download download, Audiobook audiobook, string correlationId, @@ -41,12 +43,20 @@ await RecordHistoryAsync( ["RetryCount"] = job.RetryCount }, ct); + + // Only a terminal failure (retries exhausted) is a lifecycle "import failed"; + // intermediate retries stay internal. + if (exhausted) + { + await NotifyImportFailedAsync(lifecycleNotifier, audiobook, download, reason, ct); + } } private static async Task FailImportAsync( DownloadProcessingJob job, IDownloadProcessingJobService jobService, IHistoryRepository historyRepository, + IBookLifecycleNotifier lifecycleNotifier, Download download, Audiobook audiobook, string correlationId, @@ -86,8 +96,27 @@ await RecordHistoryAsync( reason, details, ct); + + await NotifyImportFailedAsync(lifecycleNotifier, audiobook, download, reason, ct); } + private static Task NotifyImportFailedAsync( + IBookLifecycleNotifier lifecycleNotifier, + Audiobook audiobook, + Download download, + string reason, + CancellationToken ct) => + lifecycleNotifier.NotifyAsync( + NotificationTriggers.BookImportFailed, + new + { + id = audiobook.Id, + title = audiobook.Title, + downloadId = download.Id, + error = reason, + }, + ct); + private static Task RecordHistoryAsync( IHistoryRepository historyRepository, Download download, diff --git a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs index f05afe9a0..f6f34c097 100644 --- a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs +++ b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs @@ -208,6 +208,7 @@ public async Task ProcessJobAsync(DownloadProcessingJob job, CancellationToken c var downloadService = scope.ServiceProvider.GetRequiredService(); var historyRepository = scope.ServiceProvider.GetRequiredService(); + var lifecycleNotifier = scope.ServiceProvider.GetRequiredService(); var correlationId = job.GetOrCreateCorrelationId(); await downloadService.UpdateAsync(download.Importing()); @@ -229,7 +230,7 @@ await RecordHistoryAsync( (string.IsNullOrEmpty(download.DownloadPath) || (!File.Exists(download.DownloadPath) && !Directory.Exists(download.DownloadPath)))) { metrics.Increment("processing.source_missing"); - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, $"Direct-download source path not found at processing time: {download.DownloadPath}", cancellationToken); return; } @@ -245,7 +246,7 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d queueItem = await downloadItemService.GetImportItemAsync(download, cancellationToken); if (queueItem?.SourceFiles == null) { - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, isDirectDownload ? "Unable to resolve the local direct-download file" : "Unable to fetch the download from the download client", cancellationToken); @@ -258,7 +259,7 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d } catch (DownloadProcessingException exception) { - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, exception.Message, cancellationToken); return; } @@ -268,7 +269,7 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d var reason = files.Count == 0 ? "No importable files found" : "Files reported by the download client and files on disk do not match"; - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, reason, cancellationToken); return; } @@ -291,7 +292,7 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d } catch (InvalidOperationException exception) { - await FailImportAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await FailImportAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, exception.Message, cancellationToken); return; } @@ -304,7 +305,7 @@ await FailImportAsync(job, downloadProcessingJobService, historyRepository, down var failedResults = results.Where(result => !result.Success).ToList(); if (failedResults.Count > 0) { - await FailImportAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await FailImportAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, "Unable to import at least one file for the job (see the log entries)", cancellationToken, failedResults); return; @@ -317,7 +318,7 @@ await FailImportAsync(job, downloadProcessingJobService, historyRepository, down var existingAudiobookFiles = await audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id, cancellationToken); if (existingAudiobookFiles.Count <= 0) { - await FailImportAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await FailImportAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, "No audio files were registered after file import", cancellationToken); return; } @@ -382,7 +383,7 @@ await historyRepository.AddAsync(new History var downloadClientGateway = scope.ServiceProvider.GetRequiredService(); if (!await downloadClientGateway.MarkItemAsImportedAsync(client!, download, cancellationToken)) { - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, $"Unable to mark the item imported in client {client!.Id}", cancellationToken); return; } @@ -404,7 +405,7 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d } catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, $"Unable to enqueue the post-import library scan: {exception.Message}", cancellationToken); return; } @@ -449,7 +450,7 @@ await finalizationService.FinalizeAsync( } catch (InvalidOperationException exception) { - await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, download, audiobook, + await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, lifecycleNotifier, download, audiobook, correlationId, $"Unable to commit import finalization: {exception.Message}", cancellationToken); } } diff --git a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs index 412ac27eb..db3eb16d6 100644 --- a/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs +++ b/listenarr.infrastructure/Library/Moving/MoveJobProcessor.Completion.cs @@ -1,3 +1,4 @@ +using Listenarr.Domain.Notifications; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -194,6 +195,17 @@ await notificationService.SendNotificationAsync( webhook.Triggers); } + // Post-import move/organize finished: fire the standard "completed" lifecycle trigger + // (in addition to the legacy per-webhook "Moved" above), gated by the global enabled set. + var lifecycleNotifier = scope.ServiceProvider.GetRequiredService(); + await lifecycleNotifier.NotifyAsync(NotificationTriggers.BookCompleted, new + { + title = context.AudiobookTitle, + source = context.Source, + target = context.Target, + context.SourceRetained, + }); + return true; } catch (OperationCanceledException exception) diff --git a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Notifications.cs b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Notifications.cs index 5f7352f18..4cafe183a 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Notifications.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanJobProcessor.Notifications.cs @@ -2,6 +2,7 @@ * Listenarr - Audiobook Management System * Copyright (C) 2024-2026 Listenarr Contributors */ +using Listenarr.Domain.Notifications; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -28,7 +29,7 @@ private async Task NotifyAvailableAsync(Audiobook audiobook, int createdFiles) } await notificationService.SendNotificationAsync( - "book-available", + NotificationTriggers.BookAvailable, new { id = audiobook.Id, diff --git a/listenarr.infrastructure/Notifications/Delivery/BookLifecycleNotifier.cs b/listenarr.infrastructure/Notifications/Delivery/BookLifecycleNotifier.cs new file mode 100644 index 000000000..898d02356 --- /dev/null +++ b/listenarr.infrastructure/Notifications/Delivery/BookLifecycleNotifier.cs @@ -0,0 +1,50 @@ +/* + * 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. + */ +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Notifications.Delivery +{ + /// + public sealed class BookLifecycleNotifier : IBookLifecycleNotifier + { + private readonly INotificationService _notificationService; + private readonly IConfigurationService _configurationService; + private readonly ILogger _logger; + + public BookLifecycleNotifier( + INotificationService notificationService, + IConfigurationService configurationService, + ILogger logger) + { + _notificationService = notificationService; + _configurationService = configurationService; + _logger = logger; + } + + public async Task NotifyAsync(string trigger, object payload, CancellationToken cancellationToken = default) + { + try + { + var settings = await _configurationService.GetApplicationSettingsAsync(); + await _notificationService.SendNotificationAsync( + trigger, + payload, + settings.WebhookUrl, + settings.EnabledNotificationTriggers); + } + catch (Exception ex) when (ex is not OperationCanceledException + && ex is not OutOfMemoryException + && ex is not StackOverflowException) + { + _logger.LogWarning(ex, "Failed to send '{Trigger}' lifecycle notification", trigger); + } + } + } +} diff --git a/tests/Features/Api/Notifications/NotificationsControllerTests.cs b/tests/Features/Api/Notifications/NotificationsControllerTests.cs new file mode 100644 index 000000000..a462848fb --- /dev/null +++ b/tests/Features/Api/Notifications/NotificationsControllerTests.cs @@ -0,0 +1,71 @@ +/* + * 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. + */ +using Listenarr.Api.Features.Notifications; +using Listenarr.Domain.Notifications; +using Listenarr.Tests.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Tests.Features.Api.Notifications +{ + [Trait("Name", nameof(NotificationsControllerTests))] + [Trait("Category", "Notifications")] + public class NotificationsControllerTests : BaseTests + { + private static NotificationsController CreateSut() => + new( + Mock.Of(), + Mock.Of>(), + Mock.Of()); + + [Fact] + public void GetTriggers_ReturnsWholeCatalog_OrderedByLifecycle() + { + var sut = CreateSut(); + + var result = sut.GetTriggers(); + + var ok = Assert.IsType(result.Result); + var items = Assert.IsAssignableFrom(ok.Value) + .Cast() + .ToList(); + + var expectedIds = NotificationTriggers.Catalog + .OrderBy(t => t.Order) + .Select(t => t.Id) + .ToList(); + var actualIds = items + .Select(item => item.GetType().GetProperty("id")!.GetValue(item)!.ToString()!) + .ToList(); + + Assert.Equal(expectedIds, actualIds); + } + + [Fact] + public void GetTriggers_EveryItem_HasNameAndDescription() + { + var sut = CreateSut(); + + var result = sut.GetTriggers(); + + var ok = Assert.IsType(result.Result); + var items = Assert.IsAssignableFrom(ok.Value) + .Cast() + .ToList(); + + Assert.All(items, item => + { + var name = item.GetType().GetProperty("name")!.GetValue(item)?.ToString(); + var description = item.GetType().GetProperty("description")!.GetValue(item)?.ToString(); + Assert.False(string.IsNullOrWhiteSpace(name)); + Assert.False(string.IsNullOrWhiteSpace(description)); + }); + } + } +} diff --git a/tests/Features/Application/Notifications/Payloads/NotificationPayloadContentTests.cs b/tests/Features/Application/Notifications/Payloads/NotificationPayloadContentTests.cs new file mode 100644 index 000000000..bc8a7040e --- /dev/null +++ b/tests/Features/Application/Notifications/Payloads/NotificationPayloadContentTests.cs @@ -0,0 +1,77 @@ +/* + * 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. + */ + +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Application.Notifications.Payloads +{ + /// + /// Pins the human-friendly Discord "content" line the payload builder produces for each + /// lifecycle trigger, so the expanded trigger set no longer falls back to a raw "[trigger]". + /// + [Trait("Name", nameof(NotificationPayloadContentTests))] + [Trait("Category", "Notifications")] + public class NotificationPayloadContentTests : BaseTests + { + private static INotificationPayloadBuilder CreateBuilder() + { + var services = new ServiceCollection(); + services.AddSingleton(); + return services.BuildServiceProvider().GetRequiredService(); + } + + private static string Content(string trigger, object data) => + CreateBuilder() + .CreateDiscordPayload(trigger, data, "https://listenarr.example.com")! + .AsObject()["content"]! + .ToString(); + + [Theory] + [InlineData("book-wanted", "The Book by The Author is wanted")] + [InlineData("book-grabbed", "The Book by The Author was grabbed")] + [InlineData("book-download-completed", "The Book by The Author finished downloading")] + [InlineData("book-imported", "The Book by The Author was imported")] + [InlineData("book-completed", "The Book by The Author is complete")] + [InlineData("book-download-failed", "The Book by The Author failed to download")] + [InlineData("book-import-failed", "The Book by The Author failed to import")] + [InlineData("book-renamed", "The Book by The Author was renamed")] + [InlineData("book-deleted", "The Book by The Author was deleted")] + public void Content_WithTitleAndAuthor_UsesFriendlyPhrase(string trigger, string expected) + { + var data = new { title = "The Book", authors = new[] { "The Author" } }; + + Assert.Equal(expected, Content(trigger, data)); + } + + [Fact] + public void Content_WithTitleOnly_OmitsAuthorClause() + { + var data = new { title = "Solo" }; + + Assert.Equal("Solo was grabbed", Content("book-grabbed", data)); + } + + [Fact] + public void Content_WithNoTitle_FallsBackToGenericSentence() + { + var data = new { asin = "B000NOPE" }; + + Assert.Equal("An audiobook is wanted", Content("book-wanted", data)); + } + + [Fact] + public void Content_UnknownTrigger_StillUsesBracketFallback() + { + var data = new { title = "Mystery" }; + + Assert.Equal("[book-unknown] Mystery", Content("book-unknown", data)); + } + } +} diff --git a/tests/Features/Domain/Notifications/NotificationTriggersTests.cs b/tests/Features/Domain/Notifications/NotificationTriggersTests.cs new file mode 100644 index 000000000..621a54b14 --- /dev/null +++ b/tests/Features/Domain/Notifications/NotificationTriggersTests.cs @@ -0,0 +1,81 @@ +/* + * 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. + */ +using Listenarr.Domain.Notifications; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Domain.Notifications +{ + [Trait("Name", nameof(NotificationTriggersTests))] + [Trait("Category", "Notifications")] + public class NotificationTriggersTests : BaseTests + { + [Fact] + public void Catalog_HasUniqueIds() + { + var ids = NotificationTriggers.Catalog.Select(t => t.Id).ToList(); + + Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + Assert.All(ids, id => Assert.False(string.IsNullOrWhiteSpace(id))); + } + + [Fact] + public void Catalog_OrdersAreStrictlyAscending() + { + var orders = NotificationTriggers.Catalog.Select(t => t.Order).ToList(); + var sorted = orders.OrderBy(o => o).ToList(); + + Assert.Equal(sorted, orders); + Assert.Equal(orders.Count, orders.Distinct().Count()); + } + + [Fact] + public void DefaultEnabled_IsSubsetOfCatalog_AndMatchesHistoricalSet() + { + var catalogIds = NotificationTriggers.Catalog.Select(t => t.Id).ToHashSet(StringComparer.Ordinal); + + Assert.All(NotificationTriggers.DefaultEnabled, id => Assert.Contains(id, catalogIds)); + + // Back-compat: the default-enabled set must stay the four historical triggers so an + // upgrade does not start emitting new notification types unexpectedly. + Assert.Equal( + new[] + { + NotificationTriggers.BookAdded, + NotificationTriggers.BookDownloading, + NotificationTriggers.BookAvailable, + NotificationTriggers.BookCompleted, + }, + NotificationTriggers.DefaultEnabled); + } + + [Fact] + public void IsKnown_TrueForCatalogIds_FalseForOthers() + { + Assert.All( + NotificationTriggers.Catalog.Select(t => t.Id), + id => Assert.True(NotificationTriggers.IsKnown(id))); + + Assert.False(NotificationTriggers.IsKnown("book-nonexistent")); + Assert.False(NotificationTriggers.IsKnown("")); + } + + [Fact] + public void ReservedUpgradedTrigger_IsDefinedButNotYetCatalogued() + { + // book-upgraded is a reserved id with no reliable fire point yet, so it must not appear + // in the catalog (which only advertises triggers the backend actually fires). + Assert.Equal("book-upgraded", NotificationTriggers.BookUpgraded); + Assert.DoesNotContain( + NotificationTriggers.Catalog, + t => string.Equals(t.Id, NotificationTriggers.BookUpgraded, StringComparison.Ordinal)); + Assert.False(NotificationTriggers.IsKnown(NotificationTriggers.BookUpgraded)); + } + } +} diff --git a/tests/Features/Infrastructure/Notifications/Delivery/BookLifecycleNotifierTests.cs b/tests/Features/Infrastructure/Notifications/Delivery/BookLifecycleNotifierTests.cs new file mode 100644 index 000000000..f336b1017 --- /dev/null +++ b/tests/Features/Infrastructure/Notifications/Delivery/BookLifecycleNotifierTests.cs @@ -0,0 +1,91 @@ +/* + * 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. + */ +using Listenarr.Domain.Notifications; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.Notifications.Delivery +{ + [Trait("Name", nameof(BookLifecycleNotifierTests))] + [Trait("Category", "Notifications")] + public class BookLifecycleNotifierTests : BaseTests + { + private static BookLifecycleNotifier CreateSut( + Mock notificationService, + Mock configurationService) => + new( + notificationService.Object, + configurationService.Object, + Mock.Of>()); + + [Fact] + public async Task NotifyAsync_ForwardsTriggerAndPayloadWithConfiguredWebhookAndEnabledTriggers() + { + var settings = new ApplicationSettings + { + WebhookUrl = "https://hooks.example.com/abc", + EnabledNotificationTriggers = new List { NotificationTriggers.BookWanted }, + }; + var configurationService = new Mock(); + configurationService + .Setup(c => c.GetApplicationSettingsAsync()) + .ReturnsAsync(settings); + var notificationService = new Mock(); + var sut = CreateSut(notificationService, configurationService); + var payload = new { id = 42, title = "A Book" }; + + await sut.NotifyAsync(NotificationTriggers.BookWanted, payload); + + notificationService.Verify( + n => n.SendNotificationAsync( + NotificationTriggers.BookWanted, + payload, + "https://hooks.example.com/abc", + settings.EnabledNotificationTriggers), + Times.Once); + } + + [Fact] + public async Task NotifyAsync_SwallowsExceptions_AndDoesNotThrow() + { + var configurationService = new Mock(); + configurationService + .Setup(c => c.GetApplicationSettingsAsync()) + .ThrowsAsync(new InvalidOperationException("config unavailable")); + var notificationService = new Mock(); + var sut = CreateSut(notificationService, configurationService); + + var exception = await Record.ExceptionAsync( + () => sut.NotifyAsync(NotificationTriggers.BookGrabbed, new { id = 1 })); + + Assert.Null(exception); + notificationService.Verify( + n => n.SendNotificationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public async Task NotifyAsync_HonorsCancellation_WhichIsNotSwallowed() + { + var configurationService = new Mock(); + configurationService + .Setup(c => c.GetApplicationSettingsAsync()) + .ThrowsAsync(new OperationCanceledException()); + var notificationService = new Mock(); + var sut = CreateSut(notificationService, configurationService); + + await Assert.ThrowsAsync( + () => sut.NotifyAsync(NotificationTriggers.BookImported, new { id = 1 })); + } + } +} From 263fb5b4cfa3985b466e6c4f53cd90b7542b8464 Mon Sep 17 00:00:00 2001 From: dny238 Date: Fri, 18 Sep 2026 14:30:26 -0600 Subject: [PATCH 3/3] feat(notifications): fire book-upgraded when an import replaces existing files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the reserved book-upgraded trigger. The import processor now takes a pre-import file count for the audiobook (before ImportDownloadFilesAsync mutates anything); a successful import into a book that already had files is a replacement/upgrade rather than a first import. That boolean is persisted on the job (alongside SourceRetained, so it survives the FilesImported checkpoint on a resume) and threaded into ImportFinalizationService.FinalizeAsync, which fires book-upgraded right after book-imported. - NotificationTriggers: book-upgraded is now catalogued (order 55, between Imported and Available) so the settings UI can subscribe to it. Left out of DefaultEnabled (opt-in), consistent with the back-compat policy. - No grab-time quality decision is persisted, so this is the "replaced an existing book's files" proxy rather than a strict quality comparison — which for Listenarr's automatic acquisition (it only re-grabs on an allowed upgrade) is an upgrade in practice. Documented as such. Tests: FinalizeAsync fires book-upgraded when wasUpgrade is true and not on a first import; catalog test updated to assert book-upgraded is now catalogued. Build + arch gates green; 103 download-processing/notification tests pass. Co-Authored-By: Claude Opus 4.8 --- .../Contracts/IImportFinalizationService.cs | 1 + .../Notifications/NotificationTriggers.cs | 9 ++- .../Import/ImportFinalizationService.cs | 12 ++++ .../DownloadProcessingJobProcessor.cs | 20 +++++- .../NotificationTriggersTests.cs | 10 +-- .../Import/ImportFinalizationServiceTests.cs | 66 +++++++++++++++++++ 6 files changed, 107 insertions(+), 11 deletions(-) diff --git a/listenarr.application/Downloads/Contracts/IImportFinalizationService.cs b/listenarr.application/Downloads/Contracts/IImportFinalizationService.cs index b5ff35d0b..d44a4acc1 100644 --- a/listenarr.application/Downloads/Contracts/IImportFinalizationService.cs +++ b/listenarr.application/Downloads/Contracts/IImportFinalizationService.cs @@ -19,6 +19,7 @@ Task FinalizeAsync( string downloadClientId, string correlationId, bool? sourceRetained, + bool wasUpgrade = false, Dictionary? details = null, CancellationToken ct = default); } diff --git a/listenarr.domain/Notifications/NotificationTriggers.cs b/listenarr.domain/Notifications/NotificationTriggers.cs index d1466be78..c24bb3671 100644 --- a/listenarr.domain/Notifications/NotificationTriggers.cs +++ b/listenarr.domain/Notifications/NotificationTriggers.cs @@ -39,11 +39,9 @@ public static class NotificationTriggers public const string BookRenamed = "book-renamed"; /// - /// Reserved for a future "a better file replaced an existing one" event. Not in the - /// catalog yet because Listenarr's import path does not currently emit a distinct - /// upgrade-completion signal (a replacement looks identical to a first import), so - /// firing it reliably would require new pre-import state tracking. Kept as a stable id - /// so integrations and the catalog can adopt it without a breaking rename later. + /// An import replaced/added to a book that already had files — i.e. an upgrade rather + /// than a first import. Derived from a pre-import file count in the import processor + /// (there is no distinct upgrade decision persisted from grab time). /// public const string BookUpgraded = "book-upgraded"; @@ -58,6 +56,7 @@ public sealed record Definition(string Id, string DisplayName, string Descriptio new(BookDownloading, "Downloading", "A download has started.", 30), new(BookDownloadCompleted, "Download Completed", "The download client finished downloading.", 40), new(BookImported, "Imported", "The downloaded file was imported into the library.", 50), + new(BookUpgraded, "Book Upgraded", "An import replaced an existing book's files with a newer release.", 55), new(BookAvailable, "Available", "The book's files are registered and available in the library.", 60), new(BookCompleted, "Completed", "Post-import processing (move/organize) finished.", 70), new(BookDownloadFailed, "Download Failed", "A download failed.", 80), diff --git a/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs b/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs index 672987b1d..bfbf5e07c 100644 --- a/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs +++ b/listenarr.infrastructure/Downloads/Import/ImportFinalizationService.cs @@ -26,6 +26,7 @@ public async Task FinalizeAsync( string downloadClientId, string correlationId, bool? sourceRetained, + bool wasUpgrade = false, Dictionary? details = null, CancellationToken ct = default) { @@ -103,6 +104,17 @@ public async Task FinalizeAsync( downloadId, title, }, ct); + + // If the import replaced/added to a book that already had files, it's an upgrade. + if (wasUpgrade) + { + await lifecycleNotifier.NotifyAsync(NotificationTriggers.BookUpgraded, new + { + id = audiobookId, + downloadId, + title, + }, ct); + } } } } diff --git a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs index f6f34c097..c5edd82e8 100644 --- a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs +++ b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs @@ -33,6 +33,11 @@ public partial class DownloadProcessingJobProcessor( { private readonly TimeSpan _processingInterval = TimeSpan.FromSeconds(10); // Check every 10 seconds + // Persisted on the job so the "was this an upgrade" decision (derived from the pre-import + // file count) survives the FilesImported checkpoint on a resume, and can be read back at + // finalization time. Mirrors how SourceRetained is threaded to finalization. + private const string UpgradeReplacementJobDataKey = "WasUpgrade"; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Download Processing Background Service waiting for library filesystem initialization"); @@ -274,6 +279,13 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, l return; } + // Files already attached to this book BEFORE the import. A successful import into a + // non-empty book is a replacement/upgrade rather than a first-time import, which is + // what book-upgraded fires on. Taken here, before ImportDownloadFilesAsync mutates. + var audiobookFileRepository = scope.ServiceProvider.GetRequiredService(); + var preImportFileCount = (await audiobookFileRepository + .GetByAudiobookIdAsync(audiobook.Id, cancellationToken)).Count; + List results; try { @@ -314,7 +326,6 @@ await FailImportAsync(job, downloadProcessingJobService, historyRepository, life var wasRegisteredToAudiobook = results.Any(result => result.WasRegisteredToAudiobook); if (!wasRegisteredToAudiobook) { - var audiobookFileRepository = scope.ServiceProvider.GetRequiredService(); var existingAudiobookFiles = await audiobookFileRepository.GetByAudiobookIdAsync(audiobook.Id, cancellationToken); if (existingAudiobookFiles.Count <= 0) { @@ -365,6 +376,9 @@ await historyRepository.AddAsync(new History job.JobData[Download.SourceRetainedMetadataKey] = results.Any(result => result.SourceDisposition == ImportSourceDisposition.Retained); + // Reaching here means the import succeeded; if the book already had files, this + // import replaced/added to an existing book (an upgrade), not a first import. + job.JobData[UpgradeReplacementJobDataKey] = preImportFileCount > 0; job.SetCheckpoint("FilesImported", results.Count); await downloadProcessingJobService.UpdateJobAsync(job); } @@ -431,6 +445,9 @@ await RecordHistoryAsync( && bool.TryParse(sourceRetainedValue, out var parsedSourceRetained) ? parsedSourceRetained : null; + var wasUpgrade = job.TryGetJobDataString(UpgradeReplacementJobDataKey, out var wasUpgradeValue) + && bool.TryParse(wasUpgradeValue, out var parsedWasUpgrade) + && parsedWasUpgrade; try { await finalizationService.FinalizeAsync( @@ -441,6 +458,7 @@ await finalizationService.FinalizeAsync( client?.Id ?? download.DownloadClientId, correlationId, sourceRetained, + wasUpgrade, new Dictionary { ["JobId"] = job.Id, diff --git a/tests/Features/Domain/Notifications/NotificationTriggersTests.cs b/tests/Features/Domain/Notifications/NotificationTriggersTests.cs index 621a54b14..befc8049b 100644 --- a/tests/Features/Domain/Notifications/NotificationTriggersTests.cs +++ b/tests/Features/Domain/Notifications/NotificationTriggersTests.cs @@ -67,15 +67,15 @@ public void IsKnown_TrueForCatalogIds_FalseForOthers() } [Fact] - public void ReservedUpgradedTrigger_IsDefinedButNotYetCatalogued() + public void UpgradedTrigger_IsCataloguedAndKnown() { - // book-upgraded is a reserved id with no reliable fire point yet, so it must not appear - // in the catalog (which only advertises triggers the backend actually fires). + // book-upgraded now fires (an import that replaced an existing book's files), so it is + // catalogued and recognized like any other trigger. Assert.Equal("book-upgraded", NotificationTriggers.BookUpgraded); - Assert.DoesNotContain( + Assert.Contains( NotificationTriggers.Catalog, t => string.Equals(t.Id, NotificationTriggers.BookUpgraded, StringComparison.Ordinal)); - Assert.False(NotificationTriggers.IsKnown(NotificationTriggers.BookUpgraded)); + Assert.True(NotificationTriggers.IsKnown(NotificationTriggers.BookUpgraded)); } } } diff --git a/tests/Features/Infrastructure/Downloads/Import/ImportFinalizationServiceTests.cs b/tests/Features/Infrastructure/Downloads/Import/ImportFinalizationServiceTests.cs index 5c5f88853..7df96dbdc 100644 --- a/tests/Features/Infrastructure/Downloads/Import/ImportFinalizationServiceTests.cs +++ b/tests/Features/Infrastructure/Downloads/Import/ImportFinalizationServiceTests.cs @@ -8,8 +8,11 @@ * (at your option) any later version. */ using System.Text.Json; +using Listenarr.Domain.Notifications; +using Listenarr.Infrastructure.Downloads.Import; using Listenarr.Tests.Builders; using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; namespace Listenarr.Tests.Features.Infrastructure.Downloads.Import { @@ -86,5 +89,68 @@ await service.FinalizeAsync( Assert.True(details.RootElement.GetProperty("SourceRetentionKnown").GetBoolean()); Assert.True(details.RootElement.GetProperty(Download.SourceRetainedMetadataKey).GetBoolean()); } + + [Fact] + public async Task FinalizeAsync_WhenUpgrade_FiresBookImportedAndBookUpgraded() + { + var (service, notifier, job, download, audiobook, client) = await SeedFinalizableJobWithSpyAsync(); + + await service.FinalizeAsync( + job.Id, download.Id, audiobook.Id, audiobook.Title ?? download.Title, + client.Id, "finalization-upgrade", sourceRetained: false, wasUpgrade: true); + + notifier.Verify(n => n.NotifyAsync( + NotificationTriggers.BookImported, It.IsAny(), It.IsAny()), Times.Once); + notifier.Verify(n => n.NotifyAsync( + NotificationTriggers.BookUpgraded, It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task FinalizeAsync_WhenFirstImport_DoesNotFireBookUpgraded() + { + var (service, notifier, job, download, audiobook, client) = await SeedFinalizableJobWithSpyAsync(); + + await service.FinalizeAsync( + job.Id, download.Id, audiobook.Id, audiobook.Title ?? download.Title, + client.Id, "finalization-first-import", sourceRetained: false, wasUpgrade: false); + + notifier.Verify(n => n.NotifyAsync( + NotificationTriggers.BookImported, It.IsAny(), It.IsAny()), Times.Once); + notifier.Verify(n => n.NotifyAsync( + NotificationTriggers.BookUpgraded, It.IsAny(), It.IsAny()), Times.Never); + } + + private async Task<( + ImportFinalizationService service, + Mock notifier, + DownloadProcessingJob job, + Download download, + Audiobook audiobook, + DownloadClientConfiguration client)> SeedFinalizableJobWithSpyAsync() + { + var audiobook = await CreateAudiobook(); + var client = await CreateDownloadClientConfiguration(); + var download = await _downloadRepository.AddAsync(new DownloadBuilder() + .WithAudiobook(audiobook) + .WithDownloadClientConfiguration(client) + .WithStatus(DownloadStatus.ImportPending) + .Build()); + var job = new DownloadProcessingJobBuilder() + .WithDownload(download) + .WithStatus(ProcessingJobStatus.Processing) + .Build(); + job.SetCheckpoint("FilesImported"); + job.SetCheckpoint("ClientMarkedImported"); + job.SetCheckpoint("ScanEnqueued", Guid.NewGuid().ToString()); + await _downloadProcessingJobRepository.AddAsync(job); + + var notifier = new Mock(); + notifier + .Setup(n => n.NotifyAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var dbFactory = _provider.GetRequiredService>(); + var service = new ImportFinalizationService(dbFactory, notifier.Object); + return (service, notifier, job, download, audiobook, client); + } } }