mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 12:20:09 +00:00
Merge branch 'main' of https://github.com/kikootwo/ReadMeABook
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<string, string>,
|
||||
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<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 */
|
||||
export function getEventMeta(event: NotificationEvent) {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
|
||||
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}`);
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user