Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions fe/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ id: string; name: string; description: string }>>(
'/notifications/triggers',
)
}

// Application Settings
async getApplicationSettings(): Promise<ApplicationSettings> {
return this.request<ApplicationSettings>('/configuration/settings')
Expand Down
44 changes: 38 additions & 6 deletions fe/src/views/settings/NotificationsTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@
<FormSection title="Triggers" :icon="PhBell">
<div class="webhook-triggers triggers-grid">
<CheckboxCard
v-for="t in ['book-added', 'book-downloading', 'book-available', 'book-completed']"
v-for="t in orderedTriggerList"
:key="t"
:modelValue="webhookForm.triggers.includes(t)"
@update:modelValue="onToggleTriggerValue(t, $event)"
Expand Down Expand Up @@ -477,6 +477,15 @@ const webhookFormErrors = reactive({
const testingWebhookConfig = ref(false)
const savingWebhook = ref(false)

// Backend-served lifecycle-trigger catalog (single source of truth). Seeded with the historical
// set so the form still renders if the request fails; replaced on mount by GET /notifications/triggers.
const availableTriggers = ref<Array<{ id: string; name: string; description: string }>>([
{ 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
Expand Down Expand Up @@ -517,10 +526,18 @@ function generateUUID(): string {

const getTriggerIcon = (trigger: string) => {
const iconMap: Record<string, unknown> = {
'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
}
Expand Down Expand Up @@ -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<string, string> = {
'book-added': 'Book Added',
'book-downloading': 'Download Started',
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions listenarr.api/Features/Library/LibraryAddWorkflow.PostCommit.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Listenarr.Domain.Notifications;

namespace Listenarr.Api.Features.Library
{
public sealed partial class LibraryAddWorkflow
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions listenarr.api/Features/Library/LibraryDeleteWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<LibraryDeleteWorkflow> _logger;

public LibraryDeleteWorkflow(
Expand All @@ -57,6 +59,7 @@ public LibraryDeleteWorkflow(
IRootFolderService rootFolderService,
IRootFolderStorageHealthResolver storageHealthResolver,
IAudiobookFileIdentityReconciler fileIdentityReconciler,
IBookLifecycleNotifier lifecycleNotifier,
ILogger<LibraryDeleteWorkflow> logger)
{
_deletionCommitService = deletionCommitService ?? throw new ArgumentNullException(nameof(deletionCommitService));
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
{
Expand Down
20 changes: 20 additions & 0 deletions listenarr.api/Features/Notifications/NotificationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

using Listenarr.Api.Attributes;
using Listenarr.Domain.Notifications;
using Microsoft.AspNetCore.Mvc;

namespace Listenarr.Api.Features.Notifications
Expand All @@ -41,6 +42,25 @@ public NotificationsController(
_notificationService = notificationService;
}

/// <summary>
/// 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.
/// </summary>
[HttpGet("triggers")]
public ActionResult<object> GetTriggers()
{
var triggers = NotificationTriggers.Catalog
.OrderBy(trigger => trigger.Order)
.Select(trigger => new
{
id = trigger.Id,
name = trigger.DisplayName,
description = trigger.Description,
});
return Ok(triggers);
}

/// <summary>
/// Send a test notification to the configured webhook URL.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Listenarr.Domain.Notifications;
using Microsoft.Extensions.Logging;

namespace Listenarr.Application.Audiobooks.Catalog;
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 17 additions & 1 deletion listenarr.application/Audiobooks/Renaming/RenameService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
using Listenarr.Application.Common;
using Listenarr.Domain.Notifications;
using Microsoft.Extensions.Logging;

namespace Listenarr.Application.Audiobooks.Renaming
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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<List<RenamePreview>> PreviewRenameAsync(int[] audiobookIds, CancellationToken ct = default)
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Task FinalizeAsync(
string downloadClientId,
string correlationId,
bool? sourceRetained,
bool wasUpgrade = false,
Dictionary<string, object>? details = null,
CancellationToken ct = default);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

using Listenarr.Application.Common;
using Listenarr.Domain.Notifications;
using Microsoft.Extensions.Logging;

namespace Listenarr.Application.Downloads.Submission
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Fires a book-lifecycle notification for a given trigger (see
/// <see cref="Domain.Notifications.NotificationTriggers"/>). Resolves the current notification
/// settings itself and dispatches through <see cref="INotificationService"/>, so callers at
/// lifecycle transition points only supply the trigger and payload. Best-effort: it never
/// throws into the calling flow.
/// </summary>
public interface IBookLifecycleNotifier
{
Task NotifyAsync(string trigger, object payload, CancellationToken cancellationToken = default);
}
}
Loading