mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add admin request deletion with soft delete and cleanup
Implements admin ability to delete requests with soft delete, media file cleanup, and seeding-aware torrent management. Adds new API endpoint, frontend confirmation dialog, and request actions dropdown. Updates database schema with deletedAt and deletedBy fields, and ensures all queries filter out deleted requests. Documentation added for feature and user flow.
This commit is contained in:
@@ -84,7 +84,7 @@ export function useCreateRequest() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createRequest = async (audiobook: Audiobook) => {
|
||||
const createRequest = async (audiobook: Audiobook, options?: { skipAutoSearch?: boolean }) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
@@ -93,7 +93,8 @@ export function useCreateRequest() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/requests', {
|
||||
const queryParams = options?.skipAutoSearch ? '?skipAutoSearch=true' : '';
|
||||
const response = await fetchWithAuth(`/api/requests${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -290,3 +291,91 @@ export function useSelectTorrent() {
|
||||
|
||||
return { selectTorrent, isLoading, error };
|
||||
}
|
||||
|
||||
export function useSearchTorrents() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const searchTorrents = async (title: string, author: string) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/audiobooks/search-torrents', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title, author }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to search for torrents');
|
||||
}
|
||||
|
||||
return data.results || [];
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { searchTorrents, isLoading, error };
|
||||
}
|
||||
|
||||
export function useRequestWithTorrent() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const requestWithTorrent = async (audiobook: Audiobook, torrent: any) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/audiobooks/request-with-torrent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ audiobook, torrent }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to create request and download torrent');
|
||||
}
|
||||
|
||||
// Revalidate requests
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
|
||||
|
||||
// Revalidate audiobook lists
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
|
||||
|
||||
return data.request;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { requestWithTorrent, isLoading, error };
|
||||
}
|
||||
|
||||
@@ -44,10 +44,20 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed requests that have download history
|
||||
// Find all completed requests + soft-deleted requests (orphaned downloads)
|
||||
const completedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
status: { in: ['available', 'downloaded'] },
|
||||
OR: [
|
||||
// Active requests with completed downloads
|
||||
{
|
||||
status: { in: ['available', 'downloaded'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
// Soft-deleted requests (orphaned downloads still seeding)
|
||||
{
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
downloadHistory: {
|
||||
@@ -82,13 +92,13 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
// Find matching indexer configuration by name
|
||||
const seedingConfig = indexerConfigMap.get(indexerName);
|
||||
|
||||
// If no config found or seeding time is 0 (unlimited), skip
|
||||
if (!seedingConfig) {
|
||||
noConfig++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seedingConfig.seedingTimeMinutes === 0) {
|
||||
// If no config found or seeding time is 0 (unlimited)
|
||||
if (!seedingConfig || seedingConfig.seedingTimeMinutes === 0) {
|
||||
// For soft-deleted requests with unlimited seeding, hard delete immediately
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
await logger?.info(`Hard-deleted orphaned request ${request.id} with unlimited seeding`);
|
||||
}
|
||||
noConfig++;
|
||||
continue;
|
||||
}
|
||||
@@ -122,7 +132,14 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
// Delete torrent and files from qBittorrent
|
||||
await qbt.deleteTorrent(downloadHistory.downloadClientId, true); // true = delete files
|
||||
|
||||
await logger?.info(`Deleted torrent and files for request ${request.id}`);
|
||||
// If this is a soft-deleted request (orphaned download), hard delete it now
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
await logger?.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
|
||||
} else {
|
||||
await logger?.info(`Deleted torrent and files for active request ${request.id}`);
|
||||
}
|
||||
|
||||
cleaned++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
@@ -106,15 +106,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
});
|
||||
|
||||
// Get request with audiobook details
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
const request = await prisma.request.findFirst({
|
||||
where: {
|
||||
id: requestId,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request || !request.audiobook) {
|
||||
throw new Error('Request or audiobook not found');
|
||||
throw new Error('Request or audiobook not found or deleted');
|
||||
}
|
||||
|
||||
// Trigger organize files job (target path determined by database config)
|
||||
|
||||
@@ -57,9 +57,12 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
return { success: true, message: 'No RSS results', matched: 0 };
|
||||
}
|
||||
|
||||
// Get all requests awaiting search (missing audiobooks)
|
||||
// Get all active requests awaiting search (missing audiobooks)
|
||||
const missingRequests = await prisma.request.findMany({
|
||||
where: { status: 'awaiting_search' },
|
||||
where: {
|
||||
status: 'awaiting_search',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
@@ -114,25 +114,33 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
|
||||
|
||||
// Check if this is a "no files found" error that should be retried
|
||||
const isNoFilesError = errorMessage.includes('No audiobook files found');
|
||||
// Check if this is a retryable error (transient filesystem issues or no files found)
|
||||
const isRetryableError =
|
||||
errorMessage.includes('No audiobook files found') ||
|
||||
errorMessage.includes('ENOENT') || // File/directory not found
|
||||
errorMessage.includes('no such file or directory') ||
|
||||
errorMessage.includes('EACCES') || // Permission denied (might be temporary)
|
||||
errorMessage.includes('EPERM'); // Operation not permitted (might be temporary)
|
||||
|
||||
if (isNoFilesError) {
|
||||
if (isRetryableError) {
|
||||
// Get current request to check retry count
|
||||
const currentRequest = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
const currentRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
id: requestId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { importAttempts: true, maxImportRetries: true },
|
||||
});
|
||||
|
||||
if (!currentRequest) {
|
||||
throw new Error('Request not found');
|
||||
throw new Error('Request not found or deleted');
|
||||
}
|
||||
|
||||
const newAttempts = currentRequest.importAttempts + 1;
|
||||
|
||||
if (newAttempts < currentRequest.maxImportRetries) {
|
||||
// Still have retries left - queue for re-import
|
||||
await logger?.warn(`No files found for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
|
||||
await logger?.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
@@ -147,7 +155,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'No audiobook files found, queued for re-import',
|
||||
message: 'Retryable error detected, queued for re-import',
|
||||
requestId,
|
||||
attempts: newAttempts,
|
||||
maxRetries: currentRequest.maxImportRetries,
|
||||
|
||||
@@ -135,7 +135,10 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
|
||||
// Check for downloaded requests to match
|
||||
const downloadedRequests = await prisma.request.findMany({
|
||||
where: { status: 'downloaded' },
|
||||
where: {
|
||||
status: 'downloaded',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
|
||||
export interface RetryFailedImportsPayload {
|
||||
jobId?: string;
|
||||
@@ -21,10 +22,11 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
await logger?.info('Starting retry job for requests awaiting import...');
|
||||
|
||||
try {
|
||||
// Find all requests in awaiting_import status
|
||||
// Find all active requests in awaiting_import status
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'awaiting_import',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
@@ -57,17 +59,64 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
// Get the download path from the most recent download history
|
||||
const downloadHistory = request.downloadHistory[0];
|
||||
|
||||
if (!downloadHistory || !downloadHistory.downloadClientId) {
|
||||
if (!downloadHistory) {
|
||||
await logger?.warn(`No download history found for request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get download path from qBittorrent
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
|
||||
const downloadPath = `${torrent.save_path}/${torrent.name}`;
|
||||
let downloadPath: string;
|
||||
|
||||
// Try to get download path from qBittorrent if we have the torrent
|
||||
if (downloadHistory.downloadClientId) {
|
||||
try {
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
|
||||
downloadPath = `${torrent.save_path}/${torrent.name}`;
|
||||
await logger?.info(`Got download path from qBittorrent for request ${request.id}: ${downloadPath}`);
|
||||
} catch (qbtError) {
|
||||
// Torrent not found in qBittorrent - try to construct path from config
|
||||
await logger?.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
await logger?.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
await logger?.info(`Using fallback download path for request ${request.id}: ${downloadPath}`);
|
||||
}
|
||||
} else {
|
||||
// No download client ID - use fallback path
|
||||
if (!downloadHistory.torrentName) {
|
||||
await logger?.warn(`No download client ID or torrent name for request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
await logger?.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
await logger?.info(`Using configured download path for request ${request.id}: ${downloadPath}`);
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
request.id,
|
||||
|
||||
@@ -21,10 +21,11 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
await logger?.info('Starting retry job for requests awaiting search...');
|
||||
|
||||
try {
|
||||
// Find all requests in awaiting_search status
|
||||
// Find all active requests in awaiting_search status
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'awaiting_search',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
|
||||
@@ -140,7 +140,10 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
// 5. Match downloaded requests against library
|
||||
await logger?.info(`Checking for downloaded requests to match...`);
|
||||
const downloadedRequests = await prisma.request.findMany({
|
||||
where: { status: 'downloaded' },
|
||||
where: {
|
||||
status: 'downloaded',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { audiobook: true },
|
||||
take: 50, // Limit to prevent overwhelming
|
||||
});
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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:
|
||||
* 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. Soft delete request (set deletedAt, deletedBy)
|
||||
*/
|
||||
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,
|
||||
},
|
||||
},
|
||||
downloadHistory: {
|
||||
where: {
|
||||
selected: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
const downloadHistory = request.downloadHistory[0];
|
||||
|
||||
if (downloadHistory && downloadHistory.downloadClientId && 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
|
||||
);
|
||||
}
|
||||
|
||||
// Get torrent from qBittorrent
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
|
||||
let torrent;
|
||||
try {
|
||||
torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
|
||||
} catch (error) {
|
||||
// Torrent not found in qBittorrent (already removed)
|
||||
console.log(`[RequestDelete] Torrent ${downloadHistory.downloadClientId} 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
|
||||
console.log(
|
||||
`[RequestDelete] Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
|
||||
);
|
||||
torrentsKeptUnlimited++;
|
||||
} else if (!isCompleted) {
|
||||
// Download not completed - delete immediately
|
||||
console.log(
|
||||
`[RequestDelete] Deleting incomplete download: ${torrent.name}`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.downloadClientId, 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
|
||||
console.log(
|
||||
`[RequestDelete] Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
|
||||
actualSeedingTime / 60
|
||||
)}/${seedingConfig.seedingTimeMinutes} minutes)`
|
||||
);
|
||||
await qbt.deleteTorrent(downloadHistory.downloadClientId, true);
|
||||
torrentsRemoved++;
|
||||
} else {
|
||||
// Still needs seeding - keep for cleanup job
|
||||
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
||||
console.log(
|
||||
`[RequestDelete] Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
|
||||
);
|
||||
torrentsKeptSeeding++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Error handling torrent for request ${requestId}:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
// Continue with deletion even if torrent handling fails
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete media files (title folder only)
|
||||
let filesDeleted = false;
|
||||
try {
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
|
||||
|
||||
// Sanitize author and title for path
|
||||
const sanitizedAuthor = sanitizePath(request.audiobook.author);
|
||||
const sanitizedTitle = sanitizePath(request.audiobook.title);
|
||||
|
||||
// Build path: [media_dir]/[author]/[title]/
|
||||
const titleFolderPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle);
|
||||
|
||||
// Check if folder exists
|
||||
try {
|
||||
await fs.access(titleFolderPath);
|
||||
|
||||
// Delete the title folder (not the author folder)
|
||||
await fs.rm(titleFolderPath, { recursive: true, force: true });
|
||||
|
||||
console.log(`[RequestDelete] Deleted media directory: ${titleFolderPath}`);
|
||||
filesDeleted = true;
|
||||
} catch (accessError) {
|
||||
// Folder doesn't exist - that's okay
|
||||
console.log(
|
||||
`[RequestDelete] Media directory not found (already deleted?): ${titleFolderPath}`
|
||||
);
|
||||
filesDeleted = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Error deleting media files for request ${requestId}:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
// Continue with soft delete even if file deletion fails
|
||||
}
|
||||
|
||||
// 4. Soft delete request
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
deletedBy: adminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[RequestDelete] Request ${requestId} soft-deleted by admin ${adminUserId}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Request deleted successfully',
|
||||
filesDeleted,
|
||||
torrentsRemoved,
|
||||
torrentsKeptSeeding,
|
||||
torrentsKeptUnlimited,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Failed to delete request ${requestId}:`,
|
||||
error instanceof Error ? error.message : 'Unknown 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path component (removes invalid characters)
|
||||
*/
|
||||
function sanitizePath(input: string): string {
|
||||
return (
|
||||
input
|
||||
// Remove invalid path characters
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
// Trim dots and spaces from start/end
|
||||
.replace(/^[.\s]+|[.\s]+$/g, '')
|
||||
// Collapse multiple spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
// Limit length
|
||||
.substring(0, 200)
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user