Add skip-unreleased auto-search feature

Introduce an indexer-wide option to skip automatic searches for books with future release dates (config key: `indexer.skip_unreleased`, default ON). Adds a GET/PUT admin API for indexer options, a UI toggle on the Indexers settings tab (persisted on save), and persistence of a request-level releaseDate in the Prisma schema.

Adds a new request status `awaiting_release` and wires it through constants, UI components (StatusBadge, RequestCard, RecentRequestsTable, Audiobook card/modal, RequestActions), API request flows (bookdate swipe, request creation, manual search, request PATCHs, request listing groups), and services. Implements a pure release-date utility (isUnreleased / shouldSkipAutoSearch) and updates background processors: monitor-rss-feeds (skip matches but do not mutate status), retry-missing-torrents (drives bidirectional transitions between awaiting_search and awaiting_release and queues searches when appropriate), and request-creator/bookdate swipe (gate initial auto-search). Adds tests for the swipe gate and other related test updates. Logs transitions and gate decisions for observability.
This commit is contained in:
kikootwo
2026-05-15 15:35:01 -04:00
parent 5f62ba7146
commit 6f8ac86a43
37 changed files with 1289 additions and 77 deletions
+52 -13
View File
@@ -7,7 +7,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
import { shouldSkipAutoSearch } from '@/lib/utils/release-date';
const logger = RMABLogger.create('API.BookDateSwipe');
@@ -67,16 +69,21 @@ async function handler(req: AuthenticatedRequest) {
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
let releaseDate: Date | null = null;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
const parsed = new Date(audnexusData.releaseDate);
if (!isNaN(parsed.getTime())) {
releaseDate = parsed;
const releaseYear = parsed.getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
}
}
} catch (error) {
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -181,8 +188,28 @@ async function handler(req: AuthenticatedRequest) {
}
}
// Evaluate release-date gate (only when not pending approval)
let releaseGateSkip = false;
if (!needsApproval) {
try {
const configService = getConfigService();
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
const gate = shouldSkipAutoSearch({ releaseDate }, skipUnreleasedSetting);
releaseGateSkip = gate.skip;
} catch (error) {
logger.warn(`Failed to evaluate release-date gate: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Determine initial status
const initialStatus = needsApproval ? 'awaiting_approval' : 'pending';
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
} else if (releaseGateSkip) {
initialStatus = 'awaiting_release';
} else {
initialStatus = 'pending';
}
const newRequest = await prisma.request.create({
data: {
@@ -191,11 +218,21 @@ async function handler(req: AuthenticatedRequest) {
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
priority: 0,
releaseDate,
},
});
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`);
if (releaseGateSkip) {
logger.info(`Skipped auto-search for unreleased book`, {
gateSource: 'BookDateSwipe',
requestId: newRequest.id,
audiobookTitle: audiobook.title,
releaseDate: releaseDate?.toISOString() ?? null,
});
}
// Import job queue service
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
@@ -224,15 +261,17 @@ async function handler(req: AuthenticatedRequest) {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job only if auto-approved
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
// Trigger search job only if auto-approved AND not gated by release date
if (!releaseGateSkip) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
logger.info(`Triggered search job for request ${newRequest.id}`);
logger.info(`Triggered search job for request ${newRequest.id}`);
}
}
}