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
+31 -2
View File
@@ -66,6 +66,7 @@ export interface MonitorDownloadPayload extends JobPayload {
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
connectionFailureCount?: number; // Consecutive polls where the download client was unreachable
}
export interface OrganizeFilesPayload extends JobPayload {
@@ -73,6 +74,7 @@ export interface OrganizeFilesPayload extends JobPayload {
audiobookId: string;
downloadPath: string;
targetPath?: string; // Optional - not used by processor (reads from database config)
cleanupSource?: boolean; // If true, delete source files after successful import
}
export interface ScanPlexPayload extends JobPayload {
@@ -259,6 +261,29 @@ export class JobQueueService {
logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) });
}
}
// Safety net for download_torrent: if the processor skipped marking the
// request as failed (e.g. connection error with Bull retries), ensure the
// request is marked failed after all retries are exhausted.
if (job.name === 'download_torrent' && job.data) {
const payload = job.data as DownloadTorrentPayload;
logger.error(`DownloadTorrent job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`);
try {
await prisma.request.update({
where: { id: payload.requestId },
data: {
status: 'failed',
errorMessage: error.message || 'Failed to add download after multiple retries',
updatedAt: new Date(),
},
});
} catch (updateError) {
logger.error('Failed to update request status after download_torrent failure', {
error: updateError instanceof Error ? updateError.message : String(updateError),
});
}
}
});
this.queue.on('stalled', async (job: BullJob) => {
@@ -569,7 +594,8 @@ export class JobQueueService {
delaySeconds: number = 0,
lastProgress?: number,
stallCount?: number,
pathWaitCount?: number
pathWaitCount?: number,
connectionFailureCount?: number
): Promise<string> {
return await this.addJob(
'monitor_download',
@@ -581,6 +607,7 @@ export class JobQueueService {
lastProgress,
stallCount,
pathWaitCount,
connectionFailureCount,
} as MonitorDownloadPayload,
{
priority: 5, // Medium priority
@@ -597,7 +624,8 @@ export class JobQueueService {
requestId: string,
audiobookId: string,
downloadPath: string,
targetPath?: string
targetPath?: string,
cleanupSource?: boolean
): Promise<string> {
return await this.addJob(
'organize_files',
@@ -606,6 +634,7 @@ export class JobQueueService {
audiobookId,
downloadPath,
targetPath, // Not used by processor
cleanupSource,
} as OrganizeFilesPayload,
{
priority: 8,
@@ -45,13 +45,31 @@ export class AppriseProvider implements INotificationProvider {
const meta = getEventMeta(payload.event);
const { title, body } = this.formatMessage(payload);
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
const notificationType = SEVERITY_TYPES[meta.severity];
// Parse URL to extract embedded HTTP Basic Auth credentials (e.g. https://user:pass@host/)
let serverUrl: string;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
try {
const parsed = new URL(appriseConfig.serverUrl);
if (parsed.username) {
const username = decodeURIComponent(parsed.username);
const password = decodeURIComponent(parsed.password);
headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
parsed.username = '';
parsed.password = '';
serverUrl = parsed.toString().replace(/\/+$/, '');
} else {
serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
}
} catch {
serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
}
const notificationType = SEVERITY_TYPES[meta.severity];
// Explicit authToken (Bearer) takes precedence over URL-embedded credentials
if (appriseConfig.authToken) {
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
}