mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add notification system with admin UI and backend
Introduces a full notification system with support for Discord and Pushover backends, event triggers, and message formatting. Adds backend services, processors, and API endpoints for managing notifications, as well as a new Notifications tab in the admin settings UI. Updates documentation, database schema, and tests to cover notification features and approval workflow improvements. Also changes project license from MIT to AGPL v3.
This commit is contained in:
@@ -8,6 +8,7 @@ import https from 'https';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import FormData from 'form-data';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
||||
|
||||
// Handle both ESM and CommonJS imports
|
||||
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
||||
@@ -87,6 +88,7 @@ export class QBittorrentService {
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
private pathMappingConfig: PathMappingConfig;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
@@ -94,7 +96,8 @@ export class QBittorrentService {
|
||||
password: string,
|
||||
defaultSavePath: string = '/downloads',
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false
|
||||
disableSSLVerify: boolean = false,
|
||||
pathMappingConfig?: PathMappingConfig
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
@@ -102,6 +105,7 @@ export class QBittorrentService {
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
@@ -270,10 +274,14 @@ export class QBittorrentService {
|
||||
// Torrent doesn't exist, continue with adding
|
||||
}
|
||||
|
||||
// Apply reverse path mapping (local → remote) to savepath
|
||||
const localSavePath = options?.savePath || this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Upload via 'urls' parameter
|
||||
const form = new URLSearchParams({
|
||||
urls: magnetUrl,
|
||||
savepath: options?.savePath || this.defaultSavePath,
|
||||
savepath: remoteSavePath,
|
||||
category,
|
||||
paused: options?.paused ? 'true' : 'false',
|
||||
sequentialDownload: (options?.sequentialDownload !== false).toString(),
|
||||
@@ -408,6 +416,10 @@ export class QBittorrentService {
|
||||
// Torrent doesn't exist, continue with adding
|
||||
}
|
||||
|
||||
// Apply reverse path mapping (local → remote) to savepath
|
||||
const localSavePath = options?.savePath || this.defaultSavePath;
|
||||
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
||||
|
||||
// Upload .torrent file content via multipart/form-data
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -416,7 +428,7 @@ export class QBittorrentService {
|
||||
filename,
|
||||
contentType: 'application/x-bittorrent',
|
||||
});
|
||||
formData.append('savepath', options?.savePath || this.defaultSavePath);
|
||||
formData.append('savepath', remoteSavePath);
|
||||
formData.append('category', category);
|
||||
formData.append('paused', options?.paused ? 'true' : 'false');
|
||||
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
|
||||
@@ -996,6 +1008,9 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
'download_client_password',
|
||||
'download_dir',
|
||||
'download_client_disable_ssl_verify',
|
||||
'download_client_remote_path_mapping_enabled',
|
||||
'download_client_remote_path',
|
||||
'download_client_local_path',
|
||||
]);
|
||||
|
||||
logger.info('[QBittorrent] Config loaded:', {
|
||||
@@ -1004,6 +1019,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
hasPassword: !!config.download_client_password,
|
||||
hasPath: !!config.download_dir,
|
||||
disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
|
||||
pathMappingEnabled: config.download_client_remote_path_mapping_enabled === 'true',
|
||||
});
|
||||
|
||||
// Validate all required fields are present (no env var fallback)
|
||||
@@ -1035,6 +1051,13 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
const savePath = config.download_dir as string;
|
||||
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
|
||||
|
||||
// Path mapping configuration
|
||||
const pathMappingConfig: PathMappingConfig = {
|
||||
enabled: config.download_client_remote_path_mapping_enabled === 'true',
|
||||
remotePath: config.download_client_remote_path || '',
|
||||
localPath: config.download_client_local_path || '',
|
||||
};
|
||||
|
||||
logger.info('[QBittorrent] Creating service instance...');
|
||||
qbittorrentService = new QBittorrentService(
|
||||
url,
|
||||
@@ -1042,7 +1065,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
password,
|
||||
savePath,
|
||||
'readmeabook',
|
||||
disableSSLVerify
|
||||
disableSSLVerify,
|
||||
pathMappingConfig
|
||||
);
|
||||
|
||||
// Test connection
|
||||
|
||||
@@ -196,12 +196,14 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
} else if (progress.state === 'failed') {
|
||||
logger.error(`Download failed for request ${requestId}`);
|
||||
|
||||
const errorMessage = 'Download failed in qBittorrent';
|
||||
|
||||
// Update request to failed
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: 'Download failed in qBittorrent',
|
||||
errorMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -211,10 +213,33 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'failed',
|
||||
downloadError: 'Download failed in qBittorrent',
|
||||
downloadError: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
errorMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
completed: true,
|
||||
@@ -266,14 +291,38 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
|
||||
} else {
|
||||
// Permanent error - mark request as failed immediately
|
||||
const failureMessage = errorMessage || 'Monitor download failed';
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: errorMessage || 'Monitor download failed',
|
||||
errorMessage: failureMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
failureMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rethrow to trigger Bull's retry mechanism
|
||||
|
||||
@@ -253,16 +253,41 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
// Max retries exceeded - move to warn status
|
||||
logger.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
|
||||
|
||||
const warnMessage = `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`;
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'warn',
|
||||
importAttempts: newAttempts,
|
||||
errorMessage: `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`,
|
||||
errorMessage: warnMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
warnMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Max import retries exceeded, manual intervention required',
|
||||
@@ -282,6 +307,29 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (request) {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_error',
|
||||
request.id,
|
||||
request.audiobook.title,
|
||||
request.audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
errorMessage
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,14 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
|
||||
@@ -237,6 +244,19 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification that audiobook is now available
|
||||
const { getJobQueueService } = await import('../services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_available',
|
||||
request.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
matchedDownloads++;
|
||||
|
||||
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
|
||||
|
||||
@@ -366,7 +366,14 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100, // Increased from 50 to handle more eligible requests
|
||||
});
|
||||
|
||||
@@ -423,6 +430,19 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification that audiobook is now available
|
||||
const { getJobQueueService } = await import('../services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_available',
|
||||
request.id,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
matchedCount++;
|
||||
|
||||
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Send Notification Job Processor
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
* Processes notification jobs by calling NotificationService to send alerts
|
||||
* to all enabled backends subscribed to the event.
|
||||
*/
|
||||
|
||||
import { getNotificationService } from '../services/notification.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SendNotificationPayload {
|
||||
jobId?: string;
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process send notification job
|
||||
* Calls NotificationService to send notifications to all enabled backends
|
||||
*/
|
||||
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
|
||||
const { event, requestId, title, author, userName, message, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'SendNotification');
|
||||
|
||||
logger.info(`Processing notification: ${event}`, { requestId });
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
await notificationService.sendNotification({
|
||||
event,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`Notification processed: ${event}`, { requestId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to process notification', {
|
||||
event,
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ export type JobType =
|
||||
| 'retry_missing_torrents'
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds';
|
||||
| 'monitor_rss_feeds'
|
||||
| 'send_notification';
|
||||
|
||||
export interface JobPayload {
|
||||
jobId?: string; // Database job ID (added automatically by addJob)
|
||||
@@ -102,6 +103,16 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
export interface SendNotificationPayload extends JobPayload {
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
waiting: number;
|
||||
active: number;
|
||||
@@ -298,6 +309,12 @@ export class JobQueueService {
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents');
|
||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
||||
});
|
||||
|
||||
// Send notification processor
|
||||
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => {
|
||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||
return await processSendNotification(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -790,6 +807,35 @@ export class JobQueueService {
|
||||
this.redis.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add notification job
|
||||
*/
|
||||
async addNotificationJob(
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error',
|
||||
requestId: string,
|
||||
title: string,
|
||||
author: string,
|
||||
userName: string,
|
||||
message?: string
|
||||
): Promise<string> {
|
||||
logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
|
||||
return await this.addJob(
|
||||
'send_notification',
|
||||
{
|
||||
event,
|
||||
requestId,
|
||||
title,
|
||||
author,
|
||||
userName,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
} as SendNotificationPayload,
|
||||
{
|
||||
priority: 5, // Medium priority
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a repeatable job with cron schedule
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
|
||||
// Backend types
|
||||
export type NotificationBackendType =
|
||||
| 'discord'
|
||||
| 'pushover'
|
||||
| 'email'
|
||||
| 'slack'
|
||||
| 'telegram'
|
||||
| 'webhook';
|
||||
|
||||
// Config interfaces
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
appToken: string;
|
||||
device?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export type NotificationConfig = DiscordConfig | PushoverConfig;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
};
|
||||
|
||||
// Pushover priorities
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
};
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific sender
|
||||
*/
|
||||
private async sendToBackend(
|
||||
type: NotificationBackendType,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
// Decrypt config
|
||||
const decryptedConfig = this.decryptConfig(config);
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
|
||||
case 'pushover':
|
||||
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
|
||||
default:
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Discord webhook notification
|
||||
*/
|
||||
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
|
||||
const embed = this.formatDiscordEmbed(payload);
|
||||
|
||||
const body = {
|
||||
username: config.username || 'ReadMeABook',
|
||||
avatar_url: config.avatarUrl,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pushover notification
|
||||
*/
|
||||
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
|
||||
const { title, message } = this.formatPushoverMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: config.appToken,
|
||||
user: config.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
...(config.device && { device: config.device }),
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 1) {
|
||||
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Discord rich embed
|
||||
*/
|
||||
private formatDiscordEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Pushover message
|
||||
*/
|
||||
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(config: any): any {
|
||||
const decrypted = { ...config };
|
||||
|
||||
// Discord: decrypt webhookUrl
|
||||
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
|
||||
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
|
||||
}
|
||||
|
||||
// Pushover: decrypt userKey and appToken
|
||||
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
|
||||
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
|
||||
}
|
||||
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
|
||||
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: NotificationBackendType, config: any): any {
|
||||
const encrypted = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
|
||||
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
|
||||
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
|
||||
}
|
||||
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
|
||||
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: NotificationBackendType, config: any): any {
|
||||
const masked = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (masked.webhookUrl) {
|
||||
masked.webhookUrl = '••••••••';
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (masked.userKey) {
|
||||
masked.userKey = '••••••••';
|
||||
}
|
||||
if (masked.appToken) {
|
||||
masked.appToken = '••••••••';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
@@ -67,6 +67,66 @@ export class PathMapper {
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse transforms a local path to qBittorrent remote path (local-to-remote mapping)
|
||||
*
|
||||
* Example:
|
||||
* Local path: /downloads/Audiobook.Name
|
||||
* Config: { enabled: true, remotePath: 'F:\\Docker\\downloads\\completed\\books', localPath: '/downloads' }
|
||||
* Returns: F:\Docker\downloads\completed\books\Audiobook.Name
|
||||
*
|
||||
* @param localPath - Path from ReadMeABook's perspective (inside Docker)
|
||||
* @param config - Path mapping configuration
|
||||
* @returns Transformed path for qBittorrent (or original if mapping disabled/no match)
|
||||
*/
|
||||
static reverseTransform(localPath: string, config: PathMappingConfig): string {
|
||||
// 1. If mapping disabled, return original
|
||||
if (!config.enabled) {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 2. Handle empty paths
|
||||
if (!localPath || !config.remotePath || !config.localPath) {
|
||||
logger.warn('Empty path or config, returning original');
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 3. Normalize paths
|
||||
const normalizedRemote = this.normalizePath(config.remotePath);
|
||||
const normalizedLocal = this.normalizePath(config.localPath);
|
||||
const normalizedLocalPath = this.normalizePath(localPath);
|
||||
|
||||
// 4. Check if local path starts with local prefix
|
||||
if (!normalizedLocalPath.startsWith(normalizedLocal)) {
|
||||
logger.warn(
|
||||
`Path "${localPath}" does not start with local path "${config.localPath}". ` +
|
||||
`Returning original path unchanged.`
|
||||
);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 5. Replace local prefix with remote prefix
|
||||
const relativePath = normalizedLocalPath.substring(normalizedLocal.length);
|
||||
|
||||
// For remote path, preserve original path separators (important for Windows)
|
||||
// Use the original remote path's separators instead of normalizing
|
||||
const remoteSeparator = config.remotePath.includes('\\') ? '\\' : '/';
|
||||
const remotePathNormalized = config.remotePath.replace(/[/\\]+$/, ''); // Remove trailing slashes
|
||||
|
||||
// Build the final path with remote separators
|
||||
let transformedPath: string;
|
||||
if (relativePath) {
|
||||
// Convert forward slashes to remote separator
|
||||
const relativeWithRemoteSep = relativePath.replace(/^[/\\]+/, '').replace(/\//g, remoteSeparator);
|
||||
transformedPath = remotePathNormalized + remoteSeparator + relativeWithRemoteSep;
|
||||
} else {
|
||||
transformedPath = remotePathNormalized;
|
||||
}
|
||||
|
||||
logger.info(`Reverse transformed "${localPath}" to "${transformedPath}"`);
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates path mapping configuration
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user