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:
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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