/** * 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> { 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'; } }