From ba1efa88f5967f67b6ce2e0f4ba28d595d095bde Mon Sep 17 00:00:00 2001 From: xFlawless11x Date: Tue, 24 Mar 2026 22:18:31 -0400 Subject: [PATCH] feat: add On Grab notification event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds request_grabbed event that fires when a torrent/NZB is successfully handed off to the configured download client, filling the gap between request_approved (pre-search) and request_available (fully imported). - Add request_grabbed to NOTIFICATION_EVENTS with titleByRequestType (Audiobook Grabbed / Ebook Grabbed), info severity, Details messageLabel - Add NotificationEventConfig interface and update getEventMeta() return type to expose messageLabel to all providers without TypeScript errors - Add messageLabel: 'Reason' to issue_reported event - Fix all 4 providers (Discord, ntfy, Pushover, Apprise) to derive message field label from meta.messageLabel ?? 'Error' instead of hardcoded isIssue ternary — prevents grab details showing as Error - Trigger request_grabbed in download-torrent.processor.ts after client.addDownload() succeeds; message carries torrent title, indexer, and download client name; requestType sourced from request.type - Update notifications.md documentation Co-Authored-By: Claude Sonnet 4.6 --- .../backend/services/notifications.md | 10 +++++- src/lib/constants/notification-events.ts | 33 +++++++++++++++++-- .../processors/download-torrent.processor.ts | 26 ++++++++++++++- .../providers/apprise.provider.ts | 5 ++- .../providers/discord.provider.ts | 2 +- .../notification/providers/ntfy.provider.ts | 5 ++- .../providers/pushover.provider.ts | 4 ++- 7 files changed, 77 insertions(+), 8 deletions(-) diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md index aba1a9f..9ad9be9 100644 --- a/documentation/backend/services/notifications.md +++ b/documentation/backend/services/notifications.md @@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av ## Key Details - **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API) -- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported +- **Events:** request_pending_approval, request_approved, request_grabbed, request_available, request_error, issue_reported - **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs) - **Delivery:** Async via Bull job queue (priority 5) - **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) @@ -33,11 +33,14 @@ model NotificationBackend { |-------|---------|------------------------| | request_pending_approval | User creates request | Request needs admin approval | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | +| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) | | request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) | | request_error | Download/import fails | Request failed at any stage | | issue_reported | User reports issue | User reports problem with available audiobook | **Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles. +- `request_grabbed` + `requestType: 'audiobook'` → "Audiobook Grabbed" +- `request_grabbed` + `requestType: 'ebook'` → "Ebook Grabbed" - `request_available` + `requestType: 'audiobook'` → "Audiobook Available" - `request_available` + `requestType: 'ebook'` → "Ebook Available" - `request_available` + no requestType → "Request Available" (fallback) @@ -66,6 +69,11 @@ model NotificationBackend { - Approve (with or without pre-selected torrent): After job triggered → request_approved - Deny: No notification +**Download Grabbed (processor: download-torrent)** +- After `client.addDownload()` succeeds and `DownloadHistory` record created → request_grabbed +- `message` field: `"${torrent.title} via ${indexer} (${clientType})"` +- `requestType`: from `request.type` (audiobook/ebook) + **Audiobook Available (processors: scan-plex, plex-recently-added)** - After `status: 'available'` update → request_available (requestType: 'audiobook') - Includes user info in query (plexUsername) diff --git a/src/lib/constants/notification-events.ts b/src/lib/constants/notification-events.ts index 51eba7c..37bfba4 100644 --- a/src/lib/constants/notification-events.ts +++ b/src/lib/constants/notification-events.ts @@ -19,6 +19,7 @@ export type NotificationPriority = 'normal' | 'high'; * - `emoji`: Emoji prefix for notification titles * - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags) * - `priority`: Drives notification urgency (Pushover/ntfy priority levels) + * - `messageLabel`: Optional label for the `message` payload field (defaults to "Error" if omitted) */ export const NOTIFICATION_EVENTS = { request_pending_approval: { @@ -35,6 +36,18 @@ export const NOTIFICATION_EVENTS = { severity: 'success' as const, priority: 'normal' as const, }, + request_grabbed: { + label: 'Request Grabbed', + title: 'Download Grabbed', + titleByRequestType: { + audiobook: 'Audiobook Grabbed', + ebook: 'Ebook Grabbed', + } as Record, + emoji: '\u{1F4E5}', + severity: 'info' as const, + priority: 'normal' as const, + messageLabel: 'Details', + }, request_available: { label: 'Request Available', title: 'Request Available', @@ -59,6 +72,7 @@ export const NOTIFICATION_EVENTS = { emoji: '\u{1F6A9}', severity: 'warning' as const, priority: 'high' as const, + messageLabel: 'Reason', }, } as const; @@ -71,9 +85,24 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti /** Metadata shape for a single notification event */ export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent]; +/** + * Normalized interface for event metadata consumed by providers. + * Broadens the `as const` literal union to make optional fields accessible. + */ +export interface NotificationEventConfig { + label: string; + title: string; + titleByRequestType?: Record; + emoji: string; + severity: NotificationSeverity; + priority: NotificationPriority; + /** Label for the `message` payload field. Defaults to "Error" in providers when absent. */ + messageLabel?: string; +} + /** Helper: get event metadata by key */ -export function getEventMeta(event: NotificationEvent) { - return NOTIFICATION_EVENTS[event]; +export function getEventMeta(event: NotificationEvent): NotificationEventConfig { + return NOTIFICATION_EVENTS[event] as NotificationEventConfig; } /** diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index 83e9b09..eabadfc 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -103,8 +103,32 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P logger.info(`Created download history record: ${downloadHistory.id}`); - // Trigger monitor download job with initial delay + // Send grab notification + const requestWithUser = await prisma.request.findUnique({ + where: { id: requestId }, + include: { + user: { select: { plexUsername: true } }, + }, + }); + const jobQueue = getJobQueueService(); + + if (requestWithUser) { + const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`; + await jobQueue.addNotificationJob( + 'request_grabbed', + requestId, + audiobook.title, + audiobook.author, + requestWithUser.user.plexUsername || 'Unknown User', + grabMessage, + requestWithUser.type + ).catch((error) => { + logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) }); + }); + } + + // Trigger monitor download job with initial delay await jobQueue.addMonitorJob( requestId, downloadHistory.id, diff --git a/src/lib/services/notification/providers/apprise.provider.ts b/src/lib/services/notification/providers/apprise.provider.ts index ebafce9..afb7923 100644 --- a/src/lib/services/notification/providers/apprise.provider.ts +++ b/src/lib/services/notification/providers/apprise.provider.ts @@ -127,6 +127,7 @@ export class AppriseProvider implements INotificationProvider { private formatMessage(payload: NotificationPayload): { title: string; body: string } { const { event, title, author, userName, message, requestType } = payload; + const meta = getEventMeta(event); const isIssue = event === 'issue_reported'; const messageLines = [ @@ -136,7 +137,9 @@ export class AppriseProvider implements INotificationProvider { ]; if (message) { - messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`); + const messageLabel = meta.messageLabel ?? 'Error'; + const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}'; + messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`); } return { diff --git a/src/lib/services/notification/providers/discord.provider.ts b/src/lib/services/notification/providers/discord.provider.ts index f1dadcc..a52631b 100644 --- a/src/lib/services/notification/providers/discord.provider.ts +++ b/src/lib/services/notification/providers/discord.provider.ts @@ -71,7 +71,7 @@ export class DiscordProvider implements INotificationProvider { ]; if (message) { - fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false }); + fields.push({ name: meta.messageLabel ?? 'Error', value: message, inline: false }); } return { diff --git a/src/lib/services/notification/providers/ntfy.provider.ts b/src/lib/services/notification/providers/ntfy.provider.ts index e293df5..12648a0 100644 --- a/src/lib/services/notification/providers/ntfy.provider.ts +++ b/src/lib/services/notification/providers/ntfy.provider.ts @@ -84,6 +84,7 @@ export class NtfyProvider implements INotificationProvider { private formatMessage(payload: NotificationPayload): { title: string; message: string } { const { event, title, author, userName, message, requestType } = payload; + const meta = getEventMeta(event); const isIssue = event === 'issue_reported'; const messageLines = [ @@ -93,7 +94,9 @@ export class NtfyProvider implements INotificationProvider { ]; if (message) { - messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`); + const messageLabel = meta.messageLabel ?? 'Error'; + const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}'; + messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`); } return { diff --git a/src/lib/services/notification/providers/pushover.provider.ts b/src/lib/services/notification/providers/pushover.provider.ts index 19ab355..e4ccf7c 100644 --- a/src/lib/services/notification/providers/pushover.provider.ts +++ b/src/lib/services/notification/providers/pushover.provider.ts @@ -91,7 +91,9 @@ export class PushoverProvider implements INotificationProvider { ]; if (message) { - messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`); + const messageLabel = meta.messageLabel ?? 'Error'; + const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}'; + messageLines.push('', `${msgEmoji} ${messageLabel}: ${message}`); } return {