This commit is contained in:
kikootwo
2026-05-14 15:47:23 -04:00
7 changed files with 77 additions and 8 deletions
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
## Key Details ## Key Details
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API) - **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) - **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5) - **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) - **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_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | 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_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage | | request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook | | 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. **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: 'audiobook'` → "Audiobook Available"
- `request_available` + `requestType: 'ebook'` → "Ebook Available" - `request_available` + `requestType: 'ebook'` → "Ebook Available"
- `request_available` + no requestType → "Request Available" (fallback) - `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 - Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification - 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)** **Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available (requestType: 'audiobook') - After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername) - Includes user info in query (plexUsername)
+31 -2
View File
@@ -19,6 +19,7 @@ export type NotificationPriority = 'normal' | 'high';
* - `emoji`: Emoji prefix for notification titles * - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags) * - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels) * - `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 = { export const NOTIFICATION_EVENTS = {
request_pending_approval: { request_pending_approval: {
@@ -35,6 +36,18 @@ export const NOTIFICATION_EVENTS = {
severity: 'success' as const, severity: 'success' as const,
priority: 'normal' as const, priority: 'normal' as const,
}, },
request_grabbed: {
label: 'Request Grabbed',
title: 'Download Grabbed',
titleByRequestType: {
audiobook: 'Audiobook Grabbed',
ebook: 'Ebook Grabbed',
} as Record<string, string>,
emoji: '\u{1F4E5}',
severity: 'info' as const,
priority: 'normal' as const,
messageLabel: 'Details',
},
request_available: { request_available: {
label: 'Request Available', label: 'Request Available',
title: 'Request Available', title: 'Request Available',
@@ -59,6 +72,7 @@ export const NOTIFICATION_EVENTS = {
emoji: '\u{1F6A9}', emoji: '\u{1F6A9}',
severity: 'warning' as const, severity: 'warning' as const,
priority: 'high' as const, priority: 'high' as const,
messageLabel: 'Reason',
}, },
} as const; } as const;
@@ -71,9 +85,24 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti
/** Metadata shape for a single notification event */ /** Metadata shape for a single notification event */
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent]; 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<string, string>;
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 */ /** Helper: get event metadata by key */
export function getEventMeta(event: NotificationEvent) { export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
return NOTIFICATION_EVENTS[event]; return NOTIFICATION_EVENTS[event] as NotificationEventConfig;
} }
/** /**
@@ -103,8 +103,32 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
logger.info(`Created download history record: ${downloadHistory.id}`); 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(); 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( await jobQueue.addMonitorJob(
requestId, requestId,
downloadHistory.id, downloadHistory.id,
@@ -127,6 +127,7 @@ export class AppriseProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; body: string } { private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message, requestType } = payload; const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
@@ -136,7 +137,9 @@ export class AppriseProvider implements INotificationProvider {
]; ];
if (message) { 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 { return {
@@ -71,7 +71,7 @@ export class DiscordProvider implements INotificationProvider {
]; ];
if (message) { if (message) {
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false }); fields.push({ name: meta.messageLabel ?? 'Error', value: message, inline: false });
} }
return { return {
@@ -84,6 +84,7 @@ export class NtfyProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; message: string } { private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message, requestType } = payload; const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported'; const isIssue = event === 'issue_reported';
const messageLines = [ const messageLines = [
@@ -93,7 +94,9 @@ export class NtfyProvider implements INotificationProvider {
]; ];
if (message) { 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 { return {
@@ -91,7 +91,9 @@ export class PushoverProvider implements INotificationProvider {
]; ];
if (message) { 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 { return {