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
@@ -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: (() => {