mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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: (() => {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user