mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-22 06:00:12 +00:00
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:
@@ -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'}`);
|
||||
|
||||
Reference in New Issue
Block a user