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/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/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.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.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/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.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 new file mode 100644 index 000000000..c24bb3671 --- /dev/null +++ b/listenarr.domain/Notifications/NotificationTriggers.cs @@ -0,0 +1,85 @@ +/* + * 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 BookDeleted = "book-deleted"; + public const string BookRenamed = "book-renamed"; + + /// + /// 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"; + + /// 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(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), + new(BookImportFailed, "Import Failed", "An import failed.", 90), + new(BookAdded, "Book Added", "A book was added to the library.", 100), + 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. 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..bfbf5e07c 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, @@ -24,6 +26,7 @@ public async Task FinalizeAsync( string downloadClientId, string correlationId, bool? sourceRetained, + bool wasUpgrade = false, Dictionary? details = null, CancellationToken ct = default) { @@ -94,6 +97,24 @@ public async Task FinalizeAsync( job.SetCheckpoint("ImportCommitted"); await db.SaveChangesAsync(ct); + + await lifecycleNotifier.NotifyAsync(NotificationTriggers.BookImported, new + { + id = audiobookId, + 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/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..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"); @@ -208,6 +213,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 +235,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 +251,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 +264,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,11 +274,18 @@ 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; } + // 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 { @@ -291,7 +304,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 +317,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; @@ -313,11 +326,10 @@ await FailImportAsync(job, downloadProcessingJobService, historyRepository, down 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) { - 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; } @@ -364,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); } @@ -382,7 +397,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 +419,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; } @@ -430,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( @@ -440,6 +458,7 @@ await finalizationService.FinalizeAsync( client?.Id ?? download.DownloadClientId, correlationId, sourceRetained, + wasUpgrade, new Dictionary { ["JobId"] = job.Id, @@ -449,7 +468,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..befc8049b --- /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 UpgradedTrigger_IsCataloguedAndKnown() + { + // 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.Contains( + NotificationTriggers.Catalog, + t => string.Equals(t.Id, NotificationTriggers.BookUpgraded, StringComparison.Ordinal)); + 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); + } } } 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 })); + } + } +}