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
+1
View File
@@ -13,4 +13,5 @@ export const CANCELLABLE_STATUSES = [
'downloading',
'awaiting_search',
'awaiting_approval',
'awaiting_release',
] as const;
@@ -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'}`);
+37 -4
View File
@@ -9,9 +9,11 @@
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { getConfigService } from '@/lib/services/config.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { shouldSkipAutoSearch } from '@/lib/utils/release-date';
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
const logger = RMABLogger.create('RequestCreator');
@@ -95,20 +97,25 @@ export async function createRequestForUser(
}
}
// Fetch full details from Audnexus for year/series
// Fetch full details from Audnexus for year/series/releaseDate
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
let seriesAsin: string | undefined;
let releaseDate: Date | null = null;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
const parsed = new Date(audnexusData.releaseDate);
if (!isNaN(parsed.getTime())) {
releaseDate = parsed;
const releaseYear = parsed.getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
}
} catch {
// Ignore parse errors
@@ -242,12 +249,28 @@ export async function createRequestForUser(
}
}
// Evaluate release-date gate (skip-unreleased-auto-search)
let releaseGateSkip = false;
if (!needsApproval && !skipAutoSearch) {
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'}`);
}
}
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false;
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else if (releaseGateSkip) {
initialStatus = 'awaiting_release';
shouldTriggerSearch = false;
} else {
initialStatus = 'pending';
}
@@ -260,6 +283,7 @@ export async function createRequestForUser(
status: initialStatus,
type: 'audiobook',
progress: 0,
releaseDate,
},
include: {
audiobook: true,
@@ -267,6 +291,15 @@ export async function createRequestForUser(
},
});
if (releaseGateSkip) {
logger.info(`Skipped auto-search for unreleased book`, {
gateSource: 'InitialAutoSearch',
requestId: newRequest.id,
audiobookTitle: audiobookRecord.title,
releaseDate: releaseDate?.toISOString() ?? null,
});
}
const jobQueue = getJobQueueService();
// Send notification
+55
View File
@@ -0,0 +1,55 @@
/**
* Component: Release Date Utilities
* Documentation: documentation/backend/database.md
*
* Pure helpers for reasoning about a book's release date relative to "today".
* Date-only comparison in UTC — no local-timezone arithmetic and no string slicing.
*/
/**
* Returns true when the given release date is strictly after today (UTC date-only).
* Null, undefined, empty, or malformed input returns false (safe fallback).
*/
export function isUnreleased(
releaseDate: Date | string | null | undefined
): boolean {
if (releaseDate === null || releaseDate === undefined || releaseDate === '') {
return false;
}
try {
const date = releaseDate instanceof Date ? releaseDate : new Date(releaseDate);
if (isNaN(date.getTime())) {
return false;
}
const now = new Date();
const releaseY = date.getUTCFullYear();
const releaseM = date.getUTCMonth();
const releaseD = date.getUTCDate();
const nowY = now.getUTCFullYear();
const nowM = now.getUTCMonth();
const nowD = now.getUTCDate();
if (releaseY !== nowY) return releaseY > nowY;
if (releaseM !== nowM) return releaseM > nowM;
return releaseD > nowD;
} catch {
return false;
}
}
/**
* Decides whether auto-search should be skipped because the book is unreleased.
* Short-circuits when the admin toggle is off.
*/
export function shouldSkipAutoSearch(
request: { releaseDate?: Date | string | null },
settingOn: boolean
): { skip: boolean; reason?: 'unreleased' } {
if (!settingOn) return { skip: false };
if (isUnreleased(request.releaseDate)) {
return { skip: true, reason: 'unreleased' };
}
return { skip: false };
}