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:
kikootwo
2025-12-22 20:24:43 -05:00
parent bba4af7398
commit 174e9f05b6
26 changed files with 1936 additions and 200 deletions
@@ -0,0 +1,187 @@
/**
* Component: Request with Specific Torrent API
* Documentation: documentation/phase3/prowlarr.md
*
* Create a request and immediately download a specific torrent
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { z } from 'zod';
const RequestWithTorrentSchema = z.object({
audiobook: z.object({
asin: z.string(),
title: z.string(),
author: z.string(),
narrator: z.string().optional(),
description: z.string().optional(),
coverArtUrl: z.string().optional(),
durationMinutes: z.number().optional(),
releaseDate: z.string().optional(),
rating: z.number().optional(),
}),
torrent: z.object({
guid: z.string(),
title: z.string(),
size: z.number(),
seeders: z.number(),
leechers: z.number(),
indexer: z.string(),
downloadUrl: z.string(),
publishDate: z.string().transform((str) => new Date(str)),
infoHash: z.string().optional(),
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
bitrate: z.string().optional(),
hasChapters: z.boolean().optional(),
}),
});
/**
* POST /api/audiobooks/request-with-torrent
* Create a request and download a specific torrent in one operation
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const body = await req.json();
const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
// Check if audiobook is already available in Plex library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return NextResponse.json(
{
error: 'AlreadyAvailable',
message: 'This audiobook is already available in your Plex library',
plexGuid: plexMatch.plexGuid,
},
{ status: 409 }
);
}
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
// If not found, create new audiobook record
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,
status: 'requested',
},
});
}
// Check if user already has an active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
},
});
if (existingRequest) {
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return NextResponse.json(
{
error: 'DuplicateRequest',
message: 'You have already requested this audiobook',
request: existingRequest,
},
{ status: 409 }
);
}
// Delete the existing failed/warn/cancelled request
console.log(`[RequestWithTorrent] Deleting existing ${existingRequest.status} request ${existingRequest.id}`);
await prisma.request.delete({
where: { id: existingRequest.id },
});
}
// Create request with downloading status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'downloading',
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
// Queue download job with the selected torrent
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
newRequest.id,
{
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
},
torrent
);
console.log(`[RequestWithTorrent] Queued download monitor job for request ${newRequest.id}`);
return NextResponse.json({
success: true,
request: newRequest,
}, { status: 201 });
} catch (error) {
console.error('Failed to create request with torrent:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'RequestError',
message: error instanceof Error ? error.message : 'Failed to create request and download torrent',
},
{ status: 500 }
);
}
});
}
@@ -0,0 +1,114 @@
/**
* Component: Audiobook Torrent Search API
* Documentation: documentation/phase3/prowlarr.md
*
* Search for torrents without creating a request first
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { z } from 'zod';
const SearchSchema = z.object({
title: z.string(),
author: z.string(),
});
/**
* POST /api/audiobooks/search-torrents
* Search for torrents for an audiobook (no request required)
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const body = await req.json();
const { title, author } = SearchSchema.parse(body);
// Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers configured. Please configure indexers in settings.' },
{ status: 400 }
);
}
const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) {
return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
{ status: 400 }
);
}
// Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService();
const searchQuery = `${title} ${author}`;
console.log(`[AudiobookSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`);
const results = await prowlarr.search(searchQuery, {
indexerIds: enabledIndexerIds,
});
if (results.length === 0) {
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
});
}
// Rank torrents using the ranking algorithm
const rankedResults = rankTorrents(results, { title, author });
// Add rank position to each result
const resultsWithRank = rankedResults.map((result, index) => ({
...result,
rank: index + 1,
}));
console.log(`[AudiobookSearch] Found ${resultsWithRank.length} results for "${title}" by ${author}`);
return NextResponse.json({
success: true,
results: resultsWithRank,
message: `Found ${resultsWithRank.length} torrents`,
});
} catch (error) {
console.error('Failed to search for torrents:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'SearchError',
message: error instanceof Error ? error.message : 'Failed to search for torrents',
},
{ status: 500 }
);
}
});
}