mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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
|
## 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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user