Add custom search terms & retry download (admin)

Add support for per-request custom search terms and an admin retry-download flow.

- DB/schema: add custom_search_terms column via Prisma migration and schema update.
- Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions.
- API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata.
- Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms.
- UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata.
- Misc: add connection-errors util and update related processors/services and tests to cover the new flows.

These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
This commit is contained in:
kikootwo
2026-03-02 17:05:21 -05:00
parent 3ee67c8763
commit d25a6ebf79
39 changed files with 2034 additions and 311 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
const fs = await import('fs/promises');
const body = await request.json();
const { folderPath, asin } = body;
const { folderPath, asin, cleanupSource } = body;
let { audiobookId } = body;
// Validate required fields
@@ -242,7 +242,7 @@ export async function POST(request: NextRequest) {
// Queue organize_files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
@@ -0,0 +1,271 @@
/**
* Component: Admin Retry Download API
* Documentation: documentation/admin-dashboard.md
*
* Retries a failed download by either resuming monitoring of a still-alive
* download in the client, or re-adding the download using metadata from the
* most recent selected DownloadHistory record.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Requests.RetryDownload');
/** Download statuses considered "alive" — monitoring can be resumed */
const ALIVE_STATUSES = new Set([
'downloading',
'queued',
'paused',
'checking',
'seeding',
'completed',
]);
/**
* POST /api/admin/requests/[id]/retry-download
* Retry a failed download for an admin request.
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Fetch the request with audiobook info
const existingRequest = await prisma.request.findFirst({
where: { id, deletedAt: null },
include: {
audiobook: true,
},
});
if (!existingRequest) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
if (existingRequest.status !== 'failed') {
return NextResponse.json(
{
error: 'InvalidStatus',
message: `Request is not in a failed state (current status: ${existingRequest.status})`,
currentStatus: existingRequest.status,
},
{ status: 400 }
);
}
// Find the most recent selected DownloadHistory record
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId: id, selected: true },
orderBy: { createdAt: 'desc' },
});
if (!downloadHistory) {
return NextResponse.json(
{
error: 'NoHistory',
message: 'No previous download attempt found to retry',
},
{ status: 400 }
);
}
// Require a download URL to be able to re-add
if (!downloadHistory.magnetLink) {
return NextResponse.json(
{
error: 'NoDownloadUrl',
message: 'No download URL available in history to retry',
},
{ status: 400 }
);
}
const jobQueue = getJobQueueService();
let retryPath: 'resumed_monitoring' | 're_added';
// Determine if we can attempt to resume monitoring.
// downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd',
// 'nzbget', 'transmission', 'deluge', 'direct', or null).
const rawClientType: string | null = downloadHistory.downloadClient;
const clientId = downloadHistory.downloadClientId;
const isDirect = rawClientType === 'direct';
// Only attempt to query the download client if we have a known DownloadClientType,
// a clientId, and it is not a direct (HTTP) download.
const canCheckClient = !isDirect && !!rawClientType && !!clientId;
// Safe to cast here: we have already confirmed rawClientType is non-null and non-direct
const clientType = rawClientType as DownloadClientType | null;
if (canCheckClient) {
// Try to look up the download in the client
try {
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const client = await manager.getClientServiceForProtocol(protocol);
if (client) {
const downloadInfo = await client.getDownload(clientId!);
if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) {
// Download is still alive — restart monitoring
logger.info(`Retry download: resuming monitoring for request ${id}`, {
requestId: id,
downloadClientId: clientId,
downloadStatus: downloadInfo.status,
adminId: req.user.sub,
});
await jobQueue.addMonitorJob(
id,
downloadHistory.id,
clientId!, // canCheckClient guard ensures clientId is non-null
clientType as DownloadClientType,
0 // no delay — start immediately
);
retryPath = 'resumed_monitoring';
} else {
// Download not found or is failed — re-add
logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// No client configured for that protocol — fall through to re-add
logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} catch (clientError) {
// Client lookup failed (connection error etc.) — re-add to be safe
logger.warn(`Retry download: client check failed, re-adding for request ${id}`, {
requestId: id,
error: clientError instanceof Error ? clientError.message : String(clientError),
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
} else {
// Direct download (ebook), no clientId, or no clientType — re-add
logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, {
requestId: id,
adminId: req.user.sub,
});
await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory);
retryPath = 're_added';
}
// Increment downloadAttempts, clear errorMessage, set status to downloading
await prisma.request.update({
where: { id },
data: {
status: 'downloading',
errorMessage: null,
downloadAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
const message =
retryPath === 'resumed_monitoring'
? 'Download monitoring resumed'
: 'Download re-added to client';
logger.info(`Retry download completed for request ${id} via ${retryPath}`, {
requestId: id,
adminId: req.user.sub,
path: retryPath,
});
return NextResponse.json({
success: true,
message,
path: retryPath,
});
} catch (error) {
logger.error('Failed to retry download', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'RetryError',
message: 'Failed to retry download',
},
{ status: 500 }
);
}
});
});
}
/**
* Re-add the download to the queue using metadata from DownloadHistory.
* Reconstructs a TorrentResult from the stored history fields.
*/
async function reAddDownload(
jobQueue: ReturnType<typeof getJobQueueService>,
requestId: string,
audiobook: { id: string; title: string; author: string },
history: {
torrentName: string | null;
magnetLink: string | null;
indexerName: string;
indexerId: number | null;
torrentSizeBytes: bigint | null;
seeders: number | null;
leechers: number | null;
torrentHash: string | null;
torrentUrl: string | null;
}
): Promise<void> {
const torrent: TorrentResult = {
title: history.torrentName ?? audiobook.title,
downloadUrl: history.magnetLink!, // Validated non-null before calling this function
indexer: history.indexerName,
indexerId: history.indexerId ?? undefined,
size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0,
seeders: history.seeders ?? undefined,
leechers: history.leechers ?? undefined,
infoHash: history.torrentHash ?? undefined,
infoUrl: history.torrentUrl ?? undefined,
guid: history.torrentUrl ?? history.magnetLink!,
publishDate: new Date(), // Not stored; use current date as a safe default
};
await jobQueue.addDownloadJob(requestId, audiobook, torrent);
}
@@ -0,0 +1,133 @@
/**
* Component: Admin Custom Search Terms API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.SearchTerms');
/**
* PATCH /api/admin/requests/[id]/search-terms
* Update custom search terms for a request (admin only)
* Body: { searchTerms: string | null, triggerSearch?: boolean }
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
// Parse body
let body;
try {
body = await req.json();
} catch {
return NextResponse.json(
{ error: 'BadRequest', message: 'Invalid JSON body' },
{ status: 400 }
);
}
const { searchTerms, triggerSearch } = body;
// Validate searchTerms is string or null
if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') {
return NextResponse.json(
{ error: 'BadRequest', message: 'searchTerms must be a string or null' },
{ status: 400 }
);
}
// Trim and normalize
const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null;
// Find the request
const existingRequest = await prisma.request.findUnique({
where: { id },
include: {
audiobook: {
select: { id: true, title: true, author: true, audibleAsin: true },
},
},
});
if (!existingRequest || existingRequest.deletedAt) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Update custom search terms
await prisma.request.update({
where: { id },
data: {
customSearchTerms: normalizedTerms,
updatedAt: new Date(),
},
});
logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, {
requestId: id,
customSearchTerms: normalizedTerms,
adminId: req.user.id,
});
// Optionally trigger a new search
let searchTriggered = false;
if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) {
// Reset status to pending and clear error
await prisma.request.update({
where: { id },
data: {
status: 'pending',
errorMessage: null,
updatedAt: new Date(),
},
});
// Queue search job
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
}
return NextResponse.json({
success: true,
customSearchTerms: normalizedTerms,
searchTriggered,
});
} catch (error) {
logger.error('Failed to update search terms', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to update search terms' },
{ status: 500 }
);
}
});
});
}
+2
View File
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
completedAt: request.completedAt,
errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms || null,
}));
return NextResponse.json({