/** * Component: Request Deletion Service * Documentation: documentation/admin-features/request-deletion.md * * Handles soft deletion of requests with intelligent torrent/file cleanup */ import { prisma } from '../db'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RMABLogger } from '../utils/logger'; import { buildAudiobookPath } from '../utils/file-organizer'; const logger = RMABLogger.create('RequestDelete'); export interface DeleteRequestResult { success: boolean; message: string; filesDeleted: boolean; torrentsRemoved: number; torrentsKeptSeeding: number; torrentsKeptUnlimited: number; error?: string; } /** * Soft delete a request with intelligent cleanup of media files and torrents * * Logic (audiobook requests): * 1. Check if request exists and is not already deleted * 2. For each download: * - If unlimited seeding (0): Log and keep seeding, no monitoring * - If incomplete download: Delete torrent + files * - If seeding requirement met: Delete torrent + files * - If still seeding: Keep in qBittorrent for cleanup job * 3. Delete media files (title folder only) * 4. Delete from backend library (Plex/ABS) * 5. Clear audiobook availability linkage * 6. Soft delete request (set deletedAt, deletedBy) * * Logic (ebook requests): * 1. Check if request exists and is not already deleted * 2. Delete ebook files only (leave audiobook files intact) * 3. Soft delete request (set deletedAt, deletedBy) * Note: No backend library deletion or audiobook linkage clearing for ebooks */ export async function deleteRequest( requestId: string, adminUserId: string ): Promise { try { // 1. Find request (only active, non-deleted) const request = await prisma.request.findFirst({ where: { id: requestId, deletedAt: null, }, include: { audiobook: { select: { id: true, title: true, author: true, narrator: true, audibleAsin: true, plexGuid: true, absItemId: true, fileFormat: true, }, }, downloadHistory: { where: { selected: true, }, orderBy: { createdAt: 'desc', }, take: 1, }, }, }); // Determine request type (default to audiobook for backward compatibility) const requestType = (request as any)?.type || 'audiobook'; const isEbook = requestType === 'ebook'; if (!request) { return { success: false, message: 'Request not found or already deleted', filesDeleted: false, torrentsRemoved: 0, torrentsKeptSeeding: 0, torrentsKeptUnlimited: 0, error: 'NotFound', }; } let torrentsRemoved = 0; let torrentsKeptSeeding = 0; let torrentsKeptUnlimited = 0; // 2. Handle downloads & seeding (skip for ebooks - they use direct HTTP downloads) const downloadHistory = request.downloadHistory[0]; const skipTorrentHandling = isEbook; // Ebooks use direct downloads, not torrents/NZBs if (!skipTorrentHandling && downloadHistory && downloadHistory.indexerName) { try { // Get indexer seeding configuration const { getConfigService } = await import('./config.service'); const configService = getConfigService(); const indexersConfigStr = await configService.get('prowlarr_indexers'); let seedingConfig: any = null; if (indexersConfigStr) { const indexersConfig = JSON.parse(indexersConfigStr); seedingConfig = indexersConfig.find( (idx: any) => idx.name === downloadHistory.indexerName ); } // Handle based on download client type (check which ID is present) if (downloadHistory.torrentHash) { // qBittorrent download const { getQBittorrentService } = await import('../integrations/qbittorrent.service'); const qbt = await getQBittorrentService(); let torrent; try { torrent = await qbt.getTorrent(downloadHistory.torrentHash); } catch (error) { // Torrent not found in qBittorrent (already removed) logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`); } if (torrent) { // Torrent exists in qBittorrent const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0; const isCompleted = downloadHistory.downloadStatus === 'completed'; if (isUnlimitedSeeding) { // Unlimited seeding - keep in qBittorrent, stop monitoring logger.info( `Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})` ); torrentsKeptUnlimited++; } else if (!isCompleted) { // Download not completed - delete immediately logger.info( `Deleting incomplete download: ${torrent.name}` ); await qbt.deleteTorrent(downloadHistory.torrentHash, true); torrentsRemoved++; } else { // Check if seeding requirement is met const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60; const actualSeedingTime = torrent.seeding_time || 0; const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds; if (hasMetRequirement) { // Seeding requirement met - delete now logger.info( `Deleting torrent ${torrent.name} (seeding complete: ${Math.floor( actualSeedingTime / 60 )}/${seedingConfig.seedingTimeMinutes} minutes)` ); await qbt.deleteTorrent(downloadHistory.torrentHash, true); torrentsRemoved++; } else { // Still needs seeding - keep for cleanup job const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60); logger.info( `Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding` ); torrentsKeptSeeding++; } } } } else if (downloadHistory.nzbId) { // SABnzbd download - no seeding concept for Usenet try { const { getSABnzbdService } = await import('../integrations/sabnzbd.service'); const sabnzbd = await getSABnzbdService(); // Try to delete the NZB from SABnzbd (might already be completed/removed) await sabnzbd.deleteNZB(downloadHistory.nzbId, true); logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`); torrentsRemoved++; } catch (error) { // NZB not found or already removed logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`); } } } catch (error) { logger.error( `Error handling download for request ${requestId}`, { error: error instanceof Error ? error.message : String(error) } ); // Continue with deletion even if download handling fails } } // 3. Delete media files // For audiobooks: delete entire title folder // For ebooks: delete only ebook files (leave audiobook files intact) let filesDeleted = false; try { const { getConfigService } = await import('./config.service'); const configService = getConfigService(); const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks'; const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}'; // Fetch year from audible cache if ASIN is available let year: number | undefined; if (request.audiobook.audibleAsin) { const audibleCache = await prisma.audibleCache.findUnique({ where: { asin: request.audiobook.audibleAsin }, select: { releaseDate: true }, }); if (audibleCache?.releaseDate) { year = new Date(audibleCache.releaseDate).getFullYear(); } } // Build path using centralized function const titleFolderPath = buildAudiobookPath( mediaDir, template, { author: request.audiobook.author, title: request.audiobook.title, narrator: request.audiobook.narrator || undefined, asin: request.audiobook.audibleAsin || undefined, year, } ); // Check if folder exists try { await fs.access(titleFolderPath); if (isEbook) { // For ebooks: only delete ebook files, leave audiobook files intact const ebookExtensions = ['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.fb2', '.cbz', '.cbr']; const files = await fs.readdir(titleFolderPath); let deletedCount = 0; for (const file of files) { const ext = path.extname(file).toLowerCase(); if (ebookExtensions.includes(ext)) { const filePath = path.join(titleFolderPath, file); await fs.unlink(filePath); logger.info(`Deleted ebook file: ${file}`); deletedCount++; } } filesDeleted = deletedCount > 0; logger.info(`Deleted ${deletedCount} ebook file(s) from: ${titleFolderPath}`); } else { // For audiobooks: delete the entire title folder await fs.rm(titleFolderPath, { recursive: true, force: true }); logger.info(`Deleted media directory: ${titleFolderPath}`); filesDeleted = true; } } catch (accessError) { // Folder doesn't exist - that's okay logger.info(`Media directory not found: ${titleFolderPath}`); filesDeleted = false; } } catch (error) { logger.error( `Error deleting media files for request ${requestId}`, { error: error instanceof Error ? error.message : String(error) } ); // Continue with soft delete even if file deletion fails } // 4. Delete from plex_library table and clear audiobook availability // Skip for ebooks - audiobook files and library entry should remain intact // This ensures the book immediately shows as NOT available when searching if (!isEbook) { try { const { getConfigService } = await import('./config.service'); const configService = getConfigService(); const backendMode = await configService.getBackendMode(); // Delete from library backend (ABS or Plex) if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) { // Audiobookshelf: delete the library item from ABS try { const { deleteABSItem } = await import('../services/audiobookshelf/api'); await deleteABSItem(request.audiobook.absItemId); logger.info( `Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"` ); } catch (absError) { logger.error( `Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`, { error: absError instanceof Error ? absError.message : String(absError) } ); // Continue with deletion even if ABS deletion fails } } else if (backendMode === 'plex' && request.audiobook.plexGuid) { // Plex: delete the library item from Plex by ratingKey try { // Query plex_library table to get the ratingKey const plexLibraryRecord = await prisma.plexLibrary.findUnique({ where: { plexGuid: request.audiobook.plexGuid }, select: { plexRatingKey: true }, }); if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) { const ratingKey = plexLibraryRecord.plexRatingKey; // Get Plex config 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, ratingKey); logger.info( `Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"` ); } else { logger.warn('Plex server URL or token not configured, skipping Plex library deletion'); } } else { logger.warn( `No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}` ); } } catch (plexError) { logger.error( `Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`, { error: plexError instanceof Error ? plexError.message : String(plexError) } ); // Continue with deletion even if Plex deletion fails } } // Delete ALL plex_library records matching this audiobook's title and author // This handles cases where there might be duplicate library records // and ensures the book doesn't show as "In Your Library" during searches try { // Find all matching library records (by title/author fuzzy match) const matchingLibraryRecords = await prisma.plexLibrary.findMany({ where: { title: { contains: request.audiobook.title.substring(0, 20), mode: 'insensitive', }, }, }); // Filter to exact matches (case-insensitive title and author) const exactMatches = matchingLibraryRecords.filter((record) => { const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase(); const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase(); return titleMatch && authorMatch; }); if (exactMatches.length > 0) { // Delete all exact matches const deletePromises = exactMatches.map((record) => prisma.plexLibrary.delete({ where: { id: record.id } }) ); await Promise.all(deletePromises); logger.info( `Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"` ); } else { logger.info( `No plex_library records found for "${request.audiobook.title}"` ); } } catch (libError) { logger.error( `Error deleting plex_library records`, { error: libError instanceof Error ? libError.message : String(libError) } ); // Continue with deletion even if library cleanup fails } // Clear audiobook record linkage const updateData: any = { status: 'requested', // Reset to requested state updatedAt: new Date(), }; // Clear library linkage based on backend mode if (backendMode === 'audiobookshelf') { updateData.absItemId = null; } else { updateData.plexGuid = null; } await prisma.audiobook.update({ where: { id: request.audiobook.id }, data: updateData, }); logger.info( `Cleared availability status for audiobook ${request.audiobook.id}` ); } catch (error) { logger.error( `Error clearing audiobook status`, { error: error instanceof Error ? error.message : String(error) } ); // Continue with deletion even if this fails } } else { logger.info(`Skipping backend library deletion for ebook request ${requestId}`); } // 5. Delete child requests (ebook requests linked to this audiobook request) if (!isEbook) { try { const childRequests = await prisma.request.findMany({ where: { parentRequestId: requestId, deletedAt: null, }, select: { id: true, type: true, }, }); if (childRequests.length > 0) { logger.info(`Found ${childRequests.length} child request(s) to delete`); // Soft delete all child requests await prisma.request.updateMany({ where: { parentRequestId: requestId, deletedAt: null, }, data: { deletedAt: new Date(), deletedBy: adminUserId, }, }); logger.info(`Soft-deleted ${childRequests.length} child request(s)`); } } catch (error) { logger.error( `Error deleting child requests for ${requestId}`, { error: error instanceof Error ? error.message : String(error) } ); // Continue with parent deletion even if child deletion fails } } // 6. Soft delete request await prisma.request.update({ where: { id: requestId }, data: { deletedAt: new Date(), deletedBy: adminUserId, }, }); logger.info( `Request ${requestId} soft-deleted by admin ${adminUserId}` ); return { success: true, message: 'Request deleted successfully', filesDeleted, torrentsRemoved, torrentsKeptSeeding, torrentsKeptUnlimited, }; } catch (error) { logger.error( `Failed to delete request ${requestId}`, { error: error instanceof Error ? error.message : String(error) } ); return { success: false, message: 'Failed to delete request', filesDeleted: false, torrentsRemoved: 0, torrentsKeptSeeding: 0, torrentsKeptUnlimited: 0, error: error instanceof Error ? error.message : 'Unknown error', }; } }