Add reported-issues, Goodreads sync & notifs

Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
This commit is contained in:
kikootwo
2026-02-11 16:49:55 -05:00
parent b013538b63
commit 20c8fb0898
69 changed files with 4167 additions and 766 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ export async function deleteABSItem(itemId: string): Promise<void> {
throw new Error('Audiobookshelf not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}`;
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}?hard=1`;
const response = await fetch(url, {
method: 'DELETE',
+33
View File
@@ -95,6 +95,39 @@ export class EncryptionService {
}
}
/**
* Check if a value matches the format produced by encrypt().
* Validates: 3 colon-separated base64 parts where IV=16 bytes, authTag=16 bytes.
*/
isEncryptedFormat(value: string): boolean {
if (typeof value !== 'string') return false;
const parts = value.split(':');
if (parts.length !== 3) return false;
const [ivBase64, authTagBase64, encryptedBase64] = parts;
// All parts must be non-empty valid base64
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
if (!ivBase64 || !authTagBase64 || !encryptedBase64) return false;
if (!base64Regex.test(ivBase64) || !base64Regex.test(authTagBase64) || !base64Regex.test(encryptedBase64)) {
return false;
}
try {
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
// IV and authTag must decode to exactly the expected byte lengths
if (iv.length !== IV_LENGTH) return false;
if (authTag.length !== AUTH_TAG_LENGTH) return false;
return true;
} catch {
return false;
}
}
/**
* Generate a random encryption key (32 bytes)
* @returns Base64-encoded random key
+357
View File
@@ -0,0 +1,357 @@
/**
* Component: Goodreads Shelf Sync Service
* Documentation: documentation/backend/services/goodreads-sync.md
*
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
* and creates requests via the shared request-creator service.
*/
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('GoodreadsSync');
/** Default max Audible lookups per shelf per scheduled sync cycle */
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
/** Days before retrying a noMatch book */
const NO_MATCH_RETRY_DAYS = 7;
interface GoodreadsRssBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/**
* Parse a Goodreads RSS feed XML into structured book data.
*/
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
});
const parsed = parser.parse(xml);
const channel = parsed?.rss?.channel;
if (!channel) {
throw new Error('Invalid Goodreads RSS: no channel element');
}
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
// Normalize items to array
let items = channel.item;
if (!items) return { shelfName, books: [] };
if (!Array.isArray(items)) items = [items];
const books: GoodreadsRssBook[] = [];
for (const item of items) {
const bookId = item.book_id?.toString();
if (!bookId) continue;
const title = (item.title || '').toString().trim();
const authorName = (item.author_name || '').toString().trim();
// Goodreads RSS has book_image_url or book_medium_image_url
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
if (title && authorName) {
books.push({ bookId, title, author: authorName, coverUrl });
}
}
return { shelfName, books };
}
/**
* Fetch and validate a Goodreads RSS URL.
* Returns the parsed shelf name and books if valid.
*/
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
const response = await axios.get(rssUrl, { timeout: 15000 });
return parseGoodreadsRss(response.data);
}
export interface GoodreadsSyncStats {
shelvesProcessed: number;
booksFound: number;
lookupsPerformed: number;
requestsCreated: number;
errors: number;
}
export interface GoodreadsSyncOptions {
/** Process only this shelf ID (for immediate single-shelf sync) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
maxLookupsPerShelf?: number;
}
/**
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
* Called from the dedicated sync_goodreads_shelves processor.
*/
export async function processGoodreadsShelves(
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
options: GoodreadsSyncOptions = {}
): Promise<GoodreadsSyncStats> {
const log = jobLogger || logger;
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.goodreadsShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
});
if (shelves.length === 0) {
log.info(options.shelfId ? 'Shelf not found' : 'No Goodreads shelves configured, skipping');
return stats;
}
log.info(`Processing ${shelves.length} Goodreads shelf(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/shelf)` : ' (unlimited lookups)'}`);
for (const shelf of shelves) {
try {
await processShelf(shelf, stats, log, maxLookups);
stats.shelvesProcessed++;
} catch (error) {
stats.errors++;
log.error(`Failed to process shelf "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
return stats;
}
async function processShelf(
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
stats: GoodreadsSyncStats,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
maxLookups: number
) {
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
try {
rssData = await fetchAndValidateRss(shelf.rssUrl);
} catch (error) {
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
return;
}
const books = rssData.books;
stats.booksFound += books.length;
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
let lookupsThisCycle = 0;
const unlimitedLookups = maxLookups === 0;
for (const book of books) {
// Look up existing mapping
let mapping = await prisma.goodreadsBookMapping.findUnique({
where: { goodreadsBookId: book.bookId },
});
if (!mapping) {
// No mapping exists — perform Audible lookup if under cap
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
continue; // Will be resolved in a future cycle
}
mapping = await performAudibleLookup(book, log);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If lookup found an ASIN, fall through to create request immediately
if (!mapping?.audibleAsin) {
continue;
}
}
// Mapping exists with noMatch — check if we should retry
if (mapping.noMatch) {
if (mapping.lastSearchAt) {
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
mapping = await performAudibleLookup(book, log, mapping.id);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If retry found an ASIN, fall through to create request
if (!mapping?.audibleAsin) {
continue;
}
} else {
continue; // Still no match, skip
}
} else {
continue;
}
}
// Mapping has ASIN — try to create request
if (mapping.audibleAsin) {
try {
const result = await createRequestForUser(shelf.user.id, {
asin: mapping.audibleAsin,
title: mapping.title,
author: mapping.author,
coverArtUrl: mapping.coverUrl || undefined,
});
if (result.success) {
stats.requestsCreated++;
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
}
// If not success, it's already available/requested/duplicate — silently skip
} catch (error) {
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Collect enriched book data (coverUrl + ASIN) for display
const bookIds = books.map(b => b.bookId);
const mappings = bookIds.length > 0
? await prisma.goodreadsBookMapping.findMany({
where: { goodreadsBookId: { in: bookIds } },
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
})
: [];
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
// Look up AudibleCache records for high-quality cached cover URLs
const matchedAsins = mappings
.map(m => m.audibleAsin)
.filter((asin): asin is string => !!asin);
const cachedCovers = matchedAsins.length > 0
? await prisma.audibleCache.findMany({
where: { asin: { in: matchedAsins } },
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
})
: [];
const coverByAsin = new Map(
cachedCovers
.filter(c => c.cachedCoverPath || c.coverArtUrl)
.map(c => {
let coverUrl = c.coverArtUrl || '';
if (c.cachedCoverPath) {
const filename = c.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return [c.asin, coverUrl] as const;
})
);
const bookData = books
.map(b => {
const mapping = mappingsByBookId.get(b.bookId);
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
if (!coverUrl) return null;
return {
coverUrl,
asin: mapping?.audibleAsin || null,
title: mapping?.title || b.title,
author: mapping?.author || b.author,
};
})
.filter((b): b is NonNullable<typeof b> => b !== null)
.slice(0, 8);
// Update shelf metadata
await prisma.goodreadsShelf.update({
where: { id: shelf.id },
data: {
lastSyncAt: new Date(),
bookCount: books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
}
async function performAudibleLookup(
book: GoodreadsRssBook,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
existingMappingId?: string
): Promise<any> {
const audibleService = getAudibleService();
try {
const searchQuery = `${book.title} ${book.author}`;
log.info(`Searching Audible for: "${searchQuery}"`);
const searchResult = await audibleService.search(searchQuery);
const firstResult = searchResult.results[0];
if (firstResult?.asin) {
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
// Use clean Audible/Audnexus metadata instead of Goodreads data
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
const data = {
title: firstResult.title,
author: firstResult.author,
audibleAsin: firstResult.asin,
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
noMatch: false,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...data },
});
}
// No match found
log.info(`No Audible match for "${book.title}" by ${book.author}`);
const noMatchData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
audibleAsin: null,
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...noMatchData },
});
} catch (error) {
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
// Still create/update mapping so we don't retry every cycle
const errorData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...errorData },
});
}
}
+41 -4
View File
@@ -9,6 +9,7 @@ import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
import { DownloadClientType } from '../interfaces/download-client.interface';
import { RMABLogger } from '../utils/logger';
import type { NotificationEvent } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('JobQueue');
@@ -25,6 +26,7 @@ export type JobType =
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'send_notification'
// Ebook-specific job types
| 'search_ebook'
@@ -100,6 +102,12 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface SyncGoodreadsShelvesPayload extends JobPayload {
scheduledJobId?: string;
shelfId?: string;
maxLookupsPerShelf?: number;
}
// Ebook-specific payload interfaces
export interface SearchEbookPayload extends JobPayload {
requestId: string;
@@ -140,8 +148,9 @@ export interface MonitorDirectDownloadPayload extends JobPayload {
}
export interface SendNotificationPayload extends JobPayload {
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
requestId: string;
event: NotificationEvent;
requestId?: string;
issueId?: string;
title: string;
author: string;
userName: string;
@@ -340,6 +349,12 @@ export class JobQueueService {
return await processCleanupSeededTorrents(payloadWithJobId);
});
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
return await processSyncGoodreadsShelves(payloadWithJobId);
});
// Send notification processor
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => {
const { processSendNotification } = await import('../processors/send-notification.processor');
@@ -695,6 +710,23 @@ export class JobQueueService {
);
}
/**
* Add sync Goodreads shelves job
*/
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
return await this.addJob(
'sync_goodreads_shelves',
{
scheduledJobId,
shelfId,
maxLookupsPerShelf,
} as SyncGoodreadsShelvesPayload,
{
priority: 7,
}
);
}
// =========================================================================
// EBOOK-SPECIFIC JOB METHODS
// =========================================================================
@@ -911,7 +943,7 @@ export class JobQueueService {
* Add notification job
*/
async addNotificationJob(
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error',
event: NotificationEvent,
requestId: string,
title: string,
author: string,
@@ -923,11 +955,16 @@ export class JobQueueService {
'send_notification',
{
event,
requestId,
// issue_reported passes an issue ID, not a request ID — omit from payload
// so addJob doesn't try to create a FK to the requests table.
// The ID is still available in the notification payload for display.
requestId: event === 'issue_reported' ? undefined : requestId,
title,
author,
userName,
message,
// Pass the original ID for notification display (e.g., Discord footer)
...(event === 'issue_reported' && { issueId: requestId }),
timestamp: new Date(),
} as SendNotificationPayload,
{
@@ -3,24 +3,21 @@
* Documentation: documentation/backend/services/notifications.md
*/
// Event types
export type NotificationEvent =
| 'request_pending_approval'
| 'request_approved'
| 'request_available'
| 'request_error';
// Re-export event types from central source of truth
export type { NotificationEvent } from '@/lib/constants/notification-events';
// Backend type — string-based, registry is the runtime source of truth
export type NotificationBackendType = string;
// Notification payload
export interface NotificationPayload {
event: NotificationEvent;
requestId: string;
event: import('@/lib/constants/notification-events').NotificationEvent;
requestId?: string;
issueId?: string;
title: string;
author: string;
userName: string;
message?: string; // For error events
message?: string; // For error/issue events
timestamp: Date;
}
+10
View File
@@ -13,6 +13,16 @@ export type {
ProviderMetadata,
} from './INotificationProvider';
// Centralized event constants (re-exported for convenience)
export {
NOTIFICATION_EVENTS,
NOTIFICATION_EVENT_KEYS,
EVENT_LABELS,
getEventMeta,
getEventLabel,
} from '@/lib/constants/notification-events';
export type { NotificationSeverity, NotificationPriority, NotificationEventMeta } from '@/lib/constants/notification-events';
// Core service
export {
NotificationService,
@@ -130,7 +130,7 @@ export class NotificationService {
const encrypted = { ...config };
for (const field of provider.sensitiveFields) {
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
if (encrypted[field] && !this.encryptionService.isEncryptedFormat(encrypted[field])) {
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
}
}
@@ -155,25 +155,66 @@ export class NotificationService {
return masked;
}
/**
* Re-encrypt any sensitive fields that were stored as plaintext due to
* the isEncrypted() false-positive bug (URLs with exactly 2 colons).
* Safe to call multiple times — skips already-encrypted values.
*/
async reEncryptUnprotectedBackends(): Promise<number> {
let fixed = 0;
try {
const backends = await prisma.notificationBackend.findMany();
for (const backend of backends) {
const provider = getProvider(backend.type);
if (!provider) continue;
const config = backend.config as any;
let needsUpdate = false;
const updatedConfig = { ...config };
for (const field of provider.sensitiveFields) {
if (updatedConfig[field] && !this.encryptionService.isEncryptedFormat(updatedConfig[field])) {
updatedConfig[field] = this.encryptionService.encrypt(updatedConfig[field]);
needsUpdate = true;
}
}
if (needsUpdate) {
await prisma.notificationBackend.update({
where: { id: backend.id },
data: { config: updatedConfig },
});
fixed++;
logger.info(`Re-encrypted plaintext sensitive fields for backend: ${backend.name}`);
}
}
if (fixed > 0) {
logger.warn(`Re-encrypted ${fixed} backend(s) with unprotected sensitive fields`);
}
} catch (error) {
logger.error('Failed to re-encrypt backends', {
error: error instanceof Error ? error.message : String(error),
});
}
return fixed;
}
/**
* Decrypt sensitive config values
*/
private decryptConfig(sensitiveFields: string[], config: any): any {
const decrypted = { ...config };
for (const field of sensitiveFields) {
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
if (decrypted[field] && this.encryptionService.isEncryptedFormat(decrypted[field])) {
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
}
}
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;
}
}
// Singleton instance
@@ -4,6 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface AppriseConfig {
serverUrl: string;
@@ -13,12 +14,12 @@ export interface AppriseConfig {
authToken?: string;
}
// Apprise notification types by event
const APPRISE_TYPES: Record<string, string> = {
request_pending_approval: 'info',
request_approved: 'success',
request_available: 'success',
request_error: 'failure',
// Apprise notification types by severity
const SEVERITY_TYPES: Record<NotificationSeverity, string> = {
info: 'info',
success: 'success',
error: 'failure',
warning: 'warning',
};
export class AppriseProvider implements INotificationProvider {
@@ -41,10 +42,11 @@ export class AppriseProvider implements INotificationProvider {
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const appriseConfig = config as unknown as AppriseConfig;
const meta = getEventMeta(payload.event);
const { title, body } = this.formatMessage(payload);
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
const notificationType = APPRISE_TYPES[payload.event] || 'info';
const notificationType = SEVERITY_TYPES[meta.severity];
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -107,26 +109,21 @@ export class AppriseProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message } = payload;
const meta = getEventMeta(event);
const eventTitles: Record<string, string> = {
request_pending_approval: 'New Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
const isIssue = event === 'issue_reported';
const messageLines = [
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
];
if (message) {
messageLines.push(`⚠️ Error: ${message}`);
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
}
return {
title: eventTitles[event],
title: meta.title,
body: messageLines.join('\n'),
};
}
@@ -4,6 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface DiscordConfig {
webhookUrl: string;
@@ -11,20 +12,12 @@ export interface DiscordConfig {
avatarUrl?: string;
}
// 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',
// Discord embed colors by severity
const SEVERITY_COLORS: Record<NotificationSeverity, number> = {
info: 0xfbbf24, // yellow-400
success: 0x22c55e, // green-500
error: 0xef4444, // red-500
warning: 0xf97316, // orange-500
};
export class DiscordProvider implements INotificationProvider {
@@ -67,23 +60,25 @@ export class DiscordProvider implements INotificationProvider {
private formatEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const fields = [
{ name: 'Title', value: title, inline: false },
{ name: 'Author', value: author, inline: true },
{ name: 'Requested By', value: userName, inline: true },
{ name: isIssue ? 'Reported By' : 'Requested By', value: userName, inline: true },
];
if (message) {
fields.push({ name: 'Error', value: message, inline: false });
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
}
return {
title: DISCORD_TITLES[event],
color: DISCORD_COLORS[event],
title: `${meta.emoji} ${meta.title}`,
color: SEVERITY_COLORS[meta.severity],
fields,
footer: {
text: `Request ID: ${requestId}`,
text: isIssue ? `Issue ID: ${payload.issueId}` : `Request ID: ${requestId}`,
},
timestamp: timestamp.toISOString(),
};
@@ -4,6 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
export interface NtfyConfig {
serverUrl?: string;
@@ -14,20 +15,18 @@ export interface NtfyConfig {
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent)
const NTFY_PRIORITIES = {
request_pending_approval: 3, // Default
request_approved: 3, // Default
request_available: 4, // High
request_error: 4, // High
// ntfy priorities by notification priority (1=min, 2=low, 3=default, 4=high, 5=urgent)
const PRIORITY_MAP: Record<NotificationPriority, number> = {
normal: 3,
high: 4,
};
// ntfy tags (emojis) by event type
const NTFY_TAGS = {
request_pending_approval: ['mailbox_with_mail'],
request_approved: ['white_check_mark'],
request_available: ['tada'],
request_error: ['x'],
// ntfy tags (emojis) by severity
const SEVERITY_TAGS: Record<NotificationSeverity, string[]> = {
info: ['mailbox_with_mail'],
success: ['white_check_mark'],
error: ['x'],
warning: ['triangular_flag_on_post'],
};
export class NtfyProvider implements INotificationProvider {
@@ -48,10 +47,12 @@ export class NtfyProvider implements INotificationProvider {
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const ntfyConfig = config as unknown as NtfyConfig;
const meta = getEventMeta(payload.event);
const { title, message } = this.formatMessage(payload);
const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
const url = `${serverUrl}/${ntfyConfig.topic}`;
// ntfy JSON publishing requires POSTing to the base server URL (not the topic URL).
// The topic is included in the JSON body. See: https://docs.ntfy.sh/publish/#publish-as-json
const url = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -65,8 +66,8 @@ export class NtfyProvider implements INotificationProvider {
topic: ntfyConfig.topic,
title,
message,
priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event],
tags: NTFY_TAGS[payload.event],
priority: ntfyConfig.priority ?? PRIORITY_MAP[meta.priority],
tags: SEVERITY_TAGS[meta.severity],
};
const response = await fetch(url, {
@@ -83,26 +84,21 @@ export class NtfyProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
const meta = getEventMeta(event);
const eventTitles = {
request_pending_approval: 'New Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
const isIssue = event === 'issue_reported';
const messageLines = [
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
];
if (message) {
messageLines.push(`⚠️ Error: ${message}`);
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
}
return {
title: eventTitles[event],
title: meta.title,
message: messageLines.join('\n'),
};
}
@@ -4,6 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events';
export interface PushoverConfig {
userKey: string;
@@ -12,12 +13,10 @@ export interface PushoverConfig {
priority?: number;
}
// Pushover priorities by event type
const PUSHOVER_PRIORITIES = {
request_pending_approval: 0, // Normal
request_approved: 0, // Normal
request_available: 1, // High
request_error: 1, // High
// Pushover priorities by notification priority (Normal=0, High=1)
const PRIORITY_MAP: Record<NotificationPriority, number> = {
normal: 0,
high: 1,
};
export class PushoverProvider implements INotificationProvider {
@@ -48,6 +47,7 @@ export class PushoverProvider implements INotificationProvider {
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const pushoverConfig = config as unknown as PushoverConfig;
const meta = getEventMeta(payload.event);
const { title, message } = this.formatMessage(payload);
const body = new URLSearchParams({
@@ -55,7 +55,7 @@ export class PushoverProvider implements INotificationProvider {
user: pushoverConfig.userKey,
title,
message,
priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]),
priority: String(pushoverConfig.priority ?? PRIORITY_MAP[meta.priority]),
...(pushoverConfig.device && { device: pushoverConfig.device }),
});
@@ -78,43 +78,23 @@ export class PushoverProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
const meta = getEventMeta(event);
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 isIssue = event === 'issue_reported';
const messageLines = [
`${eventEmoji} ${eventTitle}`,
`${meta.emoji} ${meta.title}`,
'',
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
];
if (message) {
messageLines.push('', `⚠️ Error: ${message}`);
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
}
return {
title: eventTitle,
title: meta.title,
message: messageLines.join('\n'),
};
}
+413
View File
@@ -0,0 +1,413 @@
/**
* Component: Reported Issue Service
* Documentation: documentation/backend/services/reported-issues.md
*
* Handles user-reported problems with available audiobooks.
* Supports dismiss (admin closes) and replace (admin picks new torrent) workflows.
*/
import { prisma } from '@/lib/db';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('ReportedIssue');
/**
* Report an issue with an available audiobook
*/
export async function reportIssue(
asin: string,
reporterId: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) {
// Validate the book is in the library
const plexMatch = await findPlexMatch({
asin,
title: metadata?.title || '',
author: metadata?.author || '',
});
if (!plexMatch) {
throw new ReportedIssueError('This audiobook is not currently in your library', 404);
}
// Find or create audiobook record for this ASIN
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (!audiobook) {
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: metadata?.title || 'Unknown Title',
author: metadata?.author || 'Unknown Author',
coverArtUrl: metadata?.coverArtUrl,
status: 'requested',
},
});
logger.info(`Created audiobook record for ASIN ${asin} to link reported issue`);
}
// Check for existing open issue
const existingIssue = await prisma.reportedIssue.findFirst({
where: {
audiobookId: audiobook.id,
status: 'open',
},
});
if (existingIssue) {
throw new ReportedIssueError('An issue has already been reported for this audiobook', 409);
}
const issue = await prisma.reportedIssue.create({
data: {
audiobookId: audiobook.id,
reporterId,
reason,
},
include: {
audiobook: { select: { title: true, author: true, audibleAsin: true } },
reporter: { select: { plexUsername: true } },
},
});
logger.info(`Issue reported for "${audiobook.title}" by user ${reporterId}`);
// Queue notification (non-blocking)
try {
const { getJobQueueService } = await import('./job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'issue_reported',
issue.id,
audiobook.title,
audiobook.author,
issue.reporter.plexUsername,
reason
);
} catch (error) {
logger.error('Failed to queue issue_reported notification', {
error: error instanceof Error ? error.message : String(error),
});
}
return issue;
}
/**
* Dismiss a reported issue (admin action)
*/
export async function dismissIssue(issueId: string, adminUserId: string) {
const issue = await prisma.reportedIssue.findUnique({
where: { id: issueId },
});
if (!issue) {
throw new ReportedIssueError('Issue not found', 404);
}
if (issue.status !== 'open') {
throw new ReportedIssueError('Issue is already resolved', 409);
}
const updated = await prisma.reportedIssue.update({
where: { id: issueId },
data: {
status: 'dismissed',
resolvedAt: new Date(),
resolvedById: adminUserId,
},
});
logger.info(`Issue ${issueId} dismissed by admin ${adminUserId}`);
return updated;
}
/**
* Replace audiobook content for a reported issue (atomic admin action):
* 1. Validate issue is open
* 2. Delete old content (via request delete or direct library deletion)
* 3. Create new request + start download with selected torrent
* 4. Resolve issue as "replaced"
*/
export async function replaceAudiobook(
issueId: string,
adminUserId: string,
torrent: any
) {
const issue = await prisma.reportedIssue.findUnique({
where: { id: issueId },
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
audibleAsin: true,
coverArtUrl: true,
narrator: true,
plexGuid: true,
absItemId: true,
},
},
},
});
if (!issue) {
throw new ReportedIssueError('Issue not found', 404);
}
if (issue.status !== 'open') {
throw new ReportedIssueError('Issue is already resolved', 409);
}
const audiobook = issue.audiobook;
// Step 1: Find existing active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
// Step 2: Delete old content
if (existingRequest) {
// Has an RMAB request — use deleteRequest which handles torrent cleanup, files, library backend
const { deleteRequest } = await import('./request-delete.service');
const deleteResult = await deleteRequest(existingRequest.id, adminUserId);
if (!deleteResult.success) {
logger.warn(`deleteRequest partial failure for ${existingRequest.id}: ${deleteResult.error}`);
// Continue anyway - we want replacement to proceed
}
logger.info(`Deleted existing request ${existingRequest.id} for replacement`);
} else {
// No RMAB request — book was added to library outside RMAB
await deleteFromLibrary(audiobook);
logger.info(`Deleted library content directly for "${audiobook.title}" (no RMAB request)`);
}
// Step 3: Reset audiobook record for new request
await prisma.audiobook.update({
where: { id: audiobook.id },
data: {
status: 'requested',
plexGuid: null,
absItemId: null,
filePath: null,
fileFormat: null,
fileSizeBytes: null,
filesHash: null,
},
});
// Step 4: Create new request + start download (admin-initiated, no approval needed)
const newRequest = await prisma.request.create({
data: {
userId: adminUserId,
audiobookId: audiobook.id,
status: 'downloading',
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
// Queue download job with selected torrent
const { getJobQueueService } = await import('./job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
newRequest.id,
{
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
},
torrent
);
// Step 5: Resolve issue
await prisma.reportedIssue.update({
where: { id: issueId },
data: {
status: 'replaced',
resolvedAt: new Date(),
resolvedById: adminUserId,
},
});
logger.info(`Issue ${issueId} resolved via replacement. New request: ${newRequest.id}`);
return { issue, request: newRequest };
}
/**
* Get all open issues with audiobook metadata and reporter info (admin list)
*/
export async function getOpenIssues() {
return prisma.reportedIssue.findMany({
where: { status: 'open' },
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
coverArtUrl: true,
audibleAsin: true,
},
},
reporter: {
select: {
id: true,
plexUsername: true,
avatarUrl: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Batch query for open issues by ASINs (used for enrichment in audiobook-matcher)
*/
export async function getOpenIssuesByAsins(asins: string[]): Promise<Set<string>> {
if (asins.length === 0) return new Set();
const issues = await prisma.reportedIssue.findMany({
where: {
status: 'open',
audiobook: {
audibleAsin: { in: asins },
},
},
select: {
audiobook: {
select: { audibleAsin: true },
},
},
});
return new Set(
issues
.map((i) => i.audiobook.audibleAsin)
.filter((asin): asin is string => asin !== null)
);
}
/**
* Delete audiobook content from library backend directly (no RMAB request).
* Used when a book was added to Plex/ABS outside of RMAB.
* Mirrors the library deletion logic from request-delete.service.ts lines 280-440.
*/
async function deleteFromLibrary(audiobook: {
id: string;
title: string;
author: string;
audibleAsin: string | null;
plexGuid: string | null;
absItemId: string | null;
}) {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Delete from library backend API
if (backendMode === 'audiobookshelf') {
// absItemId may be null if the book was added outside RMAB.
// Fall back to looking up the ABS item ID from plex_library by ASIN
// (plexGuid stores the ABS item ID when using ABS backend).
let itemId = audiobook.absItemId;
if (!itemId && audiobook.audibleAsin) {
const libraryRecord = await prisma.plexLibrary.findFirst({
where: {
OR: [
{ asin: audiobook.audibleAsin },
{ plexGuid: { contains: audiobook.audibleAsin } },
],
},
select: { plexGuid: true },
});
itemId = libraryRecord?.plexGuid ?? null;
}
if (itemId) {
try {
const { deleteABSItem } = await import('./audiobookshelf/api');
await deleteABSItem(itemId);
logger.info(`Deleted ABS item ${itemId} for "${audiobook.title}"`);
} catch (error) {
logger.error(`Failed to delete ABS item ${itemId}`, {
error: error instanceof Error ? error.message : String(error),
});
}
} else {
logger.warn(`No ABS item ID found for "${audiobook.title}" (ASIN: ${audiobook.audibleAsin}) — skipping ABS deletion`);
}
} else if (backendMode === 'plex' && audiobook.plexGuid) {
try {
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: audiobook.plexGuid },
select: { plexRatingKey: true },
});
if (plexLibraryRecord?.plexRatingKey) {
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, plexLibraryRecord.plexRatingKey);
logger.info(`Deleted Plex item ${plexLibraryRecord.plexRatingKey} for "${audiobook.title}"`);
}
}
} catch (error) {
logger.error(`Failed to delete Plex item for "${audiobook.title}"`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
// Delete plex_library records by ASIN
if (audiobook.audibleAsin) {
try {
const result = await prisma.plexLibrary.deleteMany({
where: {
OR: [
{ asin: audiobook.audibleAsin },
{ plexGuid: { contains: audiobook.audibleAsin } },
],
},
});
if (result.count > 0) {
logger.info(`Deleted ${result.count} plex_library record(s) by ASIN "${audiobook.audibleAsin}"`);
}
} catch (error) {
logger.error(`Failed to delete plex_library records for ASIN "${audiobook.audibleAsin}"`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Custom error class for reported issues
*/
export class ReportedIssueError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = 'ReportedIssueError';
}
}
+267
View File
@@ -0,0 +1,267 @@
/**
* Component: Request Creator Service
* Documentation: documentation/backend/services/requests.md
*
* Shared request-creation logic used by both the API route and Goodreads sync.
* Encapsulates: duplicate detection, library check, Audnexus enrichment,
* audiobook record creation, approval flow, notification queuing, and search job triggering.
*/
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('RequestCreator');
export interface CreateRequestInput {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
}
export interface CreateRequestOptions {
skipAutoSearch?: boolean;
}
export type CreateRequestResult =
| { success: true; request: any }
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
/**
* Create a request for a user, with full duplicate detection, library checks,
* Audnexus enrichment, approval flow, notifications, and search job triggering.
*/
export async function createRequestForUser(
userId: string,
audiobook: CreateRequestInput,
options: CreateRequestOptions = {}
): Promise<CreateRequestResult> {
const { skipAutoSearch = false } = options;
// Check for existing active request (downloaded/available) for this ASIN
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: { audibleAsin: audiobook.asin },
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
if (existingActiveRequest) {
const status = existingActiveRequest.status;
return {
success: false,
reason: status === 'available' ? 'already_available' : 'being_processed',
message: status === 'available'
? 'This audiobook is already available in your library'
: 'This audiobook is being processed and will be available soon',
};
}
// Check if audiobook is already in Plex/ABS library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return {
success: false,
reason: 'already_available',
message: 'This audiobook is already available in your library',
};
}
// Fetch full details from Audnexus for year/series
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
} catch {
// Ignore parse errors
}
}
if (audnexusData?.series) series = audnexusData.series;
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Find or create audiobook record
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
year,
series,
seriesPart,
status: 'requested',
},
});
logger.debug(`Created audiobook ${audiobookRecord.id} for ASIN ${audiobook.asin}`);
} else {
// Update existing record with clean metadata (e.g. Audnexus title replacing Goodreads title)
const updates: Record<string, any> = {};
if (audiobook.title && audiobook.title !== audiobookRecord.title) updates.title = audiobook.title;
if (audiobook.author && audiobook.author !== audiobookRecord.author) updates.author = audiobook.author;
if (audiobook.coverArtUrl && !audiobookRecord.coverArtUrl) updates.coverArtUrl = audiobook.coverArtUrl;
if (year) updates.year = year;
if (series) updates.series = series;
if (seriesPart) updates.seriesPart = seriesPart;
if (Object.keys(updates).length > 0) {
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: updates,
});
}
}
// Check if user already has an active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId,
audiobookId: audiobookRecord.id,
type: 'audiobook',
deletedAt: null,
},
});
if (existingRequest) {
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return {
success: false,
reason: 'duplicate',
message: 'You have already requested this audiobook',
};
}
// Delete existing failed/warn/cancelled request
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({ where: { id: existingRequest.id } });
}
// Check ANY user's active request for same audiobook (avoid duplicate processing)
const anyActiveRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobookRecord.id,
type: 'audiobook',
status: { notIn: ['failed', 'warn', 'cancelled', 'available', 'downloaded'] },
deletedAt: null,
},
});
if (anyActiveRequest && anyActiveRequest.userId !== userId) {
return {
success: false,
reason: 'being_processed',
message: 'This audiobook is already being requested by another user',
};
}
// Determine if approval is needed
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, autoApproveRequests: true, plexUsername: true },
});
if (!user) {
return { success: false, reason: 'user_not_found', message: 'User not found' };
}
if (user.role === 'admin') {
needsApproval = false;
} else {
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false;
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
const jobQueue = getJobQueueService();
// Send notification
const notificationType = initialStatus === 'awaiting_approval' ? 'request_pending_approval' : 'request_approved';
await jobQueue.addNotificationJob(
notificationType,
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return { success: true, request: newRequest };
}
+22 -1
View File
@@ -4,12 +4,13 @@
*/
import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
import { getNotificationService } from './notification';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Scheduler');
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds';
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
export interface ScheduledJob {
id: string;
@@ -49,6 +50,9 @@ export class SchedulerService {
async start(): Promise<void> {
logger.info('Initializing scheduler service...');
// Re-encrypt any notification backends with plaintext sensitive fields
await getNotificationService().reEncryptUnprotectedBackends();
// Create default jobs if they don't exist
await this.ensureDefaultJobs();
@@ -115,6 +119,13 @@ export class SchedulerService {
enabled: true, // Enable by default
payload: {},
},
{
name: 'Sync Goodreads Shelves',
type: 'sync_goodreads_shelves' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default
payload: {},
},
];
for (const defaultJob of defaults) {
@@ -314,6 +325,9 @@ export class SchedulerService {
case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job);
break;
case 'sync_goodreads_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job);
break;
default:
throw new Error(`Unknown job type: ${job.type}`);
}
@@ -578,6 +592,13 @@ export class SchedulerService {
private async triggerCleanupSeededTorrents(job: any): Promise<string> {
return await this.jobQueue.addCleanupSeededTorrentsJob(job.id);
}
/**
* Trigger Goodreads shelves sync
*/
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
}
}
// Singleton instance