Files
ReadMeABook/src/lib/services/request-delete.service.ts
T
kikootwo 590f089733 Add first-class ebook request support and UI
Implements first-class ebook requests with their own type, parent-child relationship to audiobook requests, and separate status flow. Updates database schema and migrations to support 'type' and 'parentRequestId' fields on requests. Adds processors and job types for ebook search and direct HTTP download from Anna's Archive, with FlareSolverr integration for Cloudflare bypass. Enhances admin UI tables and request actions to display and manage ebook requests, including orange badge and source links. Updates documentation to reflect new ebook support, configuration, and behavior.
2026-01-30 15:59:25 -05:00

499 lines
18 KiB
TypeScript

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