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
@@ -8,6 +8,7 @@
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { shouldSkipAutoSearch } from '../utils/release-date';
export interface MonitorRssFeedsPayload {
jobId?: string;
@@ -25,6 +26,9 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
const configService = getConfigService();
const indexersConfigStr = await configService.get('prowlarr_indexers');
// Read skip-unreleased setting once at start (default ON when absent)
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
if (!indexersConfigStr) {
logger.warn(`No indexers configured, skipping`);
return { success: false, message: 'No indexers configured', skipped: true };
@@ -95,6 +99,21 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
if (hasAuthor && titleMatchCount >= 2) {
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
// Release-date gate: skip RSS-driven auto-search for unreleased books.
// Does NOT mutate request.status — retry job is the sole owner of
// awaiting_search ↔ awaiting_release transitions.
const gate = shouldSkipAutoSearch({ releaseDate: request.releaseDate }, skipUnreleasedSetting);
if (gate.skip) {
logger.info(`Skipped RSS auto-search for unreleased book`, {
gateSource: 'MonitorRssFeeds',
requestId: request.id,
audiobookTitle: audiobook.title,
releaseDate: request.releaseDate?.toISOString() ?? null,
});
// Match exists but is gated — preserve "only trigger once per request" semantics.
break;
}
// Trigger appropriate search job based on request type
try {
if (request.type === 'ebook') {
@@ -2,12 +2,17 @@
* Component: Retry Missing Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries search for requests that are awaiting torrent search
* Retries search for requests that are awaiting torrent search.
* Also drives bidirectional transitions between `awaiting_search` and
* `awaiting_release` based on the per-book release date and the
* `indexer.skip_unreleased` setting.
*/
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { getConfigService } from '../services/config.service';
import { shouldSkipAutoSearch } from '../utils/release-date';
export interface RetryMissingTorrentsPayload {
jobId?: string;
@@ -15,77 +20,146 @@ export interface RetryMissingTorrentsPayload {
}
export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const { jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'RetryMissingTorrents');
logger.info('Starting retry job for requests awaiting search...');
logger.info('Starting retry job for requests awaiting search/release...');
try {
// Find all active requests (audiobook or ebook) in awaiting_search status
// Read skip-unreleased setting once at start (default ON when absent)
const configService = getConfigService();
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
// Find all active requests in awaiting_search OR awaiting_release status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_search',
status: { in: ['awaiting_search', 'awaiting_release'] },
deletedAt: null,
},
include: {
audiobook: true,
},
take: 50, // Limit to 50 requests per run
take: 50,
});
logger.info(`Found ${requests.length} requests awaiting search`);
logger.info(`Found ${requests.length} requests awaiting search/release`);
if (requests.length === 0) {
return {
success: true,
message: 'No requests awaiting search',
message: 'No requests awaiting search/release',
triggered: 0,
transitioned: 0,
skipped: 0,
};
}
// Trigger appropriate search job for each request based on type
// Throttle: 100ms delay between jobs to avoid connection pool burst
const jobQueue = getJobQueueService();
let triggered = 0;
let transitioned = 0;
let skipped = 0;
for (const request of requests) {
try {
if (request.type === 'ebook') {
// Ebook requests use ebook search (Anna's Archive, etc.)
await jobQueue.addSearchEbookJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
const gate = shouldSkipAutoSearch({ releaseDate: request.releaseDate }, skipUnreleasedSetting);
if (request.status === 'awaiting_search' && gate.skip) {
// Future release, setting ON → demote to awaiting_release
await prisma.request.update({
where: { id: request.id },
data: { status: 'awaiting_release' },
});
skipped++;
transitioned++;
logger.info(`Transitioned request to awaiting_release (unreleased)`, {
gateSource: 'RetryMissingTorrents',
requestId: request.id,
audiobookTitle: request.audiobook.title,
releaseDate: request.releaseDate?.toISOString() ?? null,
from: 'awaiting_search',
to: 'awaiting_release',
});
} else if (request.status === 'awaiting_release' && !gate.skip) {
// Released (or setting OFF) → promote to awaiting_search and run search.
// Order: update status → queue job → log (race safety).
await prisma.request.update({
where: { id: request.id },
data: { status: 'awaiting_search' },
});
if (request.type === 'ebook') {
await jobQueue.addSearchEbookJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
} else {
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
}
triggered++;
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
transitioned++;
logger.info(`Transitioned request to awaiting_search and queued search`, {
requestId: request.id,
audiobookTitle: request.audiobook.title,
releaseDate: request.releaseDate?.toISOString() ?? null,
from: 'awaiting_release',
to: 'awaiting_search',
triggeredBy: 'RetryMissingTorrents',
});
} else if (request.status === 'awaiting_release' && gate.skip) {
// Still unreleased — leave as-is.
skipped++;
logger.info(`Skipped awaiting_release request (still unreleased)`, {
gateSource: 'RetryMissingTorrents',
requestId: request.id,
audiobookTitle: request.audiobook.title,
releaseDate: request.releaseDate?.toISOString() ?? null,
});
} else {
// Audiobook requests use indexer search (Prowlarr)
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
// awaiting_search + !gate.skip → existing search path
if (request.type === 'ebook') {
await jobQueue.addSearchEbookJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
} else {
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
}
}
} catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
logger.error(`Failed to process request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Spread DB operations over time to avoid connection pool exhaustion
await new Promise(resolve => setTimeout(resolve, 100));
}
logger.info(`Triggered ${triggered}/${requests.length} search jobs`);
logger.info(`Retry pass complete: triggered=${triggered}, transitioned=${transitioned}, skipped=${skipped} of ${requests.length}`);
return {
success: true,
message: 'Retry missing torrents completed',
totalRequests: requests.length,
triggered,
transitioned,
skipped,
};
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);