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