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
@@ -55,6 +55,7 @@ const STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'awaiting_approval', label: 'Awaiting Approval' },
{ value: 'awaiting_search', label: 'Awaiting Search' },
{ value: 'awaiting_release', label: 'Awaiting Release' },
{ value: 'searching', label: 'Searching' },
{ value: 'downloading', label: 'Downloading' },
{ value: 'processing', label: 'Processing' },
@@ -78,6 +79,7 @@ function getStatusBadge(status: string) {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
awaiting_release: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
@@ -95,6 +97,7 @@ function getStatusBadge(status: string) {
const labels: Record<string, string> = {
awaiting_search: 'Awaiting Search',
awaiting_release: 'Awaiting Release',
awaiting_import: 'Awaiting Import',
awaiting_approval: 'Awaiting Approval',
};
@@ -69,8 +69,8 @@ export function RequestActionsDropdown({
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canSearch = ['pending', 'failed', 'awaiting_search', 'awaiting_release'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'awaiting_release', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const canDelete = true; // Admins can always delete
+11
View File
@@ -113,6 +113,17 @@ export const saveTabSettings = async (
}).then(res => {
if (!res.ok) throw new Error('Failed to save indexer configuration');
});
// Save indexer-wide options (auto-search behavior, etc.)
await fetchWithAuth('/api/admin/settings/indexer-options', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skipUnreleased: settings.indexerOptions.skipUnreleased,
}),
}).then(res => {
if (!res.ok) throw new Error('Failed to save indexer options');
});
break;
case 'download':
+14
View File
@@ -16,6 +16,7 @@ export interface Settings {
oidc: OIDCSettings;
registration: RegistrationSettings;
prowlarr: ProwlarrSettings;
indexerOptions: IndexerOptionsSettings;
downloadClient: DownloadClientSettings;
paths: PathsSettings;
ebook: EbookSettings;
@@ -76,6 +77,19 @@ export interface ProwlarrSettings {
apiKey: string;
}
/**
* Indexer-wide behavioral options (not tied to a specific indexer connection).
* Persisted via `/api/admin/settings/indexer-options`.
*/
export interface IndexerOptionsSettings {
/**
* When true, automatic indexer searches skip books whose release date is
* in the future. Default ON. Manual searches are unaffected.
* Backing config key: `indexer.skip_unreleased`.
*/
skipUnreleased: boolean;
}
/**
* Download client (qBittorrent) configuration
*/
@@ -136,6 +136,48 @@ export function IndexersTab({
)}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Auto-Search Behavior
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Control how ReadMeABook performs automatic background searches across your indexers.
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="indexer-skip-unreleased"
checked={settings.indexerOptions.skipUnreleased}
onChange={(e) =>
onChange({
...settings,
indexerOptions: {
...settings.indexerOptions,
skipUnreleased: e.target.checked,
},
})
}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="indexer-skip-unreleased"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Skip unreleased books in automatic searches
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When ON, ReadMeABook will not search indexers for books whose release date is in the future. These requests will automatically begin searching once the book is released. Manual searches are not affected.
</p>
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<IndexerManagement
prowlarrUrl={settings.prowlarr.url}
@@ -0,0 +1,114 @@
/**
* Component: Admin Indexer Options Settings API
* Documentation: documentation/settings-pages.md
*
* Manages indexer-wide behavioral options that are not tied to a specific
* indexer connection (e.g., auto-search behavior toggles).
*
* Read contract (consumed by background auto-search workers):
* - Config key: `indexer.skip_unreleased`
* - Category: `indexer`
* - Value: string `'true'` | `'false'`
* - Default: ON when the key is missing OR its value is anything other
* than the exact string `'false'`. In other words, skipping
* unreleased books is enabled unless the admin explicitly
* opted out. Workers MUST match this contract:
*
* const skip = (await config.get('indexer.skip_unreleased')) !== 'false';
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.IndexerOptions');
const CONFIG_KEY = 'indexer.skip_unreleased';
/**
* GET /api/admin/settings/indexer-options
* Returns the current indexer-wide options.
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const configService = getConfigService();
const value = await configService.get(CONFIG_KEY);
// Default ON: missing or any value other than 'false' is treated as enabled.
const skipUnreleased = value !== 'false';
return NextResponse.json({ skipUnreleased });
} catch (error) {
logger.error('Failed to fetch indexer options', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to fetch indexer options' },
{ status: 500 }
);
}
});
});
}
/**
* PUT /api/admin/settings/indexer-options
* Persists indexer-wide options. Body: { skipUnreleased: boolean }
*/
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { skipUnreleased } = body ?? {};
if (typeof skipUnreleased !== 'boolean') {
return NextResponse.json(
{ error: 'skipUnreleased must be a boolean' },
{ status: 400 }
);
}
const configService = getConfigService();
await configService.setMany([
{
key: CONFIG_KEY,
value: String(skipUnreleased),
category: 'indexer',
description:
'Skip auto-searches for books with future release dates',
},
]);
// Explicitly clear cache for the key after write. `setMany` already
// does this, but we make it visible here to guarantee fresh reads
// by any sibling service that has cached the value.
configService.clearCache(CONFIG_KEY);
logger.info('Indexer options updated', { skipUnreleased });
return NextResponse.json({
success: true,
message: 'Indexer options updated successfully',
});
} catch (error) {
logger.error('Failed to update indexer options', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'Failed to update indexer options',
},
{ status: 500 }
);
}
});
});
}
+6
View File
@@ -81,6 +81,12 @@ export async function GET(request: NextRequest) {
url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
},
indexerOptions: {
// Default ON: missing or any value other than 'false' is treated as enabled.
// Must stay in lock-step with /api/admin/settings/indexer-options read contract
// and any background worker that reads `indexer.skip_unreleased` directly.
skipUnreleased: configMap.get('indexer.skip_unreleased') !== 'false',
},
// downloadClient is populated from multi-client format for backward compatibility
// The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients
downloadClient: (() => {
+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}`);
}
}
}
@@ -52,8 +52,8 @@ export async function POST(
);
}
// Only allow manual search for pending, failed, awaiting_search statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search'];
// Only allow manual search for pending, failed, awaiting_search, awaiting_release statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search', 'awaiting_release'];
if (!searchableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
{
+1 -1
View File
@@ -182,7 +182,7 @@ export async function PATCH(
} else if (action === 'retry') {
// Retry failed request - allow users to retry their own warn/failed requests
// Only allow retry for failed, warn, or awaiting_* statuses
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import'];
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import', 'awaiting_release'];
if (!retryableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
+1 -1
View File
@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
// Status groups for server-side filtering and count aggregation
const STATUS_GROUPS: Record<string, string[]> = {
active: ['pending', 'searching', 'downloading', 'processing'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval', 'awaiting_release'],
completed: ['available', 'downloaded'],
failed: ['failed'],
cancelled: ['cancelled', 'denied'],
+1 -1
View File
@@ -34,7 +34,7 @@ const getStatusConfig = (audiobook: Audiobook) => {
return { type: 'processing', label: 'Processing', color: 'amber' };
}
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval'];
if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) {
return { type: 'pending', label: 'Requested', color: 'blue' };
}
@@ -53,7 +53,7 @@ const getStatusInfo = (isAvailable: boolean, requestStatus: string | null, reque
return { type: 'processing', label: 'Processing', canRequest: false };
}
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval'];
if (requestStatus && pendingStatuses.includes(requestStatus)) {
const label = requestStatus === 'awaiting_approval'
? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval'
+18
View File
@@ -27,6 +27,7 @@ interface RequestCardProps {
updatedAt: string;
completedAt?: string;
downloadAvailable?: boolean;
releaseDate?: string | Date | null;
audiobook: {
id: string;
audibleAsin?: string;
@@ -58,6 +59,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
const releaseDateLabel = React.useMemo(() => {
if (request.status !== 'awaiting_release' || !request.releaseDate) return null;
const parsed = new Date(request.releaseDate);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}, [request.status, request.releaseDate]);
const handleConfirmCancel = async () => {
try {
await cancelRequest(request.id);
@@ -150,6 +163,11 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
{/* Status Badge and Type Badge */}
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={request.status} progress={request.progress} />
{releaseDateLabel && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Releases {releaseDateLabel}
</span>
)}
{isEbook && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
+4
View File
@@ -68,6 +68,10 @@ export function StatusBadge({ status, progress, className }: StatusBadgeProps) {
label: 'Pending Approval',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
},
awaiting_release: {
label: 'Awaiting Release',
color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
},
denied: {
label: 'Request Denied',
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
+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 };
}