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
+137 -9
View File
@@ -32,19 +32,28 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: vi.fn().mockResolvedValue(null),
}));
// Mock AudibleService
// Mock AudibleService (default = no Audnexus data)
const audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
getAudibleService: () => audibleServiceMock,
}));
// Mock job queue (shared across tests so we can assert addSearchJob calls)
const jobQueueAddSearchJob = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const jobQueueAddNotificationJob = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => ({
addSearchJob: jobQueueAddSearchJob,
addNotificationJob: jobQueueAddNotificationJob,
}),
}));
// Mock job queue
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => ({
addSearchJob: vi.fn().mockResolvedValue(undefined),
addNotificationJob: vi.fn().mockResolvedValue(undefined),
}),
// Mock config service for indexer.skip_unreleased setting
const configServiceGet = vi.hoisted(() => vi.fn());
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => ({ get: configServiceGet }),
}));
// Mock getSiblingAsins from works.service
@@ -68,6 +77,10 @@ describe('createRequestForUser — ignore list', () => {
beforeEach(() => {
vi.clearAllMocks();
// Restore mock return values cleared by clearAllMocks
jobQueueAddSearchJob.mockResolvedValue(undefined);
jobQueueAddNotificationJob.mockResolvedValue(undefined);
// Default: no existing requests, no library matches
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
@@ -97,6 +110,10 @@ describe('createRequestForUser — ignore list', () => {
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
mockGetSiblingAsins.mockResolvedValue(new Map());
mockSeedAsin.mockResolvedValue(undefined);
// Default Audnexus + config behaviour
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
configServiceGet.mockResolvedValue(null); // default → setting ON
});
it('blocks auto-request when ASIN is directly ignored', async () => {
@@ -198,3 +215,114 @@ describe('createRequestForUser — ignore list', () => {
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
});
});
describe('createRequestForUser — release-date gate', () => {
beforeEach(() => {
vi.clearAllMocks();
jobQueueAddSearchJob.mockResolvedValue(undefined);
jobQueueAddNotificationJob.mockResolvedValue(undefined);
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: TEST_AUDIOBOOK.asin,
title: TEST_AUDIOBOOK.title,
author: TEST_AUDIOBOOK.author,
narrator: null,
});
prismaMock.user.findUnique.mockResolvedValue({
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser',
});
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
mockGetSiblingAsins.mockResolvedValue(new Map());
mockSeedAsin.mockResolvedValue(undefined);
});
it('creates request in awaiting_release with no search when book is unreleased and setting ON', async () => {
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: future });
configServiceGet.mockResolvedValue(null); // default → ON
prismaMock.request.create.mockResolvedValue({
id: 'request-future-on',
userId: TEST_USER_ID,
audiobookId: 'audiobook-1',
status: 'awaiting_release',
audiobook: { id: 'audiobook-1', title: 'Test Book' },
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_release',
releaseDate: expect.any(Date),
}),
})
);
expect(jobQueueAddSearchJob).not.toHaveBeenCalled();
});
it('creates pending request and runs search when book is already released and setting ON', async () => {
const past = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: past });
configServiceGet.mockResolvedValue('true');
prismaMock.request.create.mockResolvedValue({
id: 'request-past-on',
userId: TEST_USER_ID,
audiobookId: 'audiobook-1',
status: 'pending',
audiobook: { id: 'audiobook-1', title: 'Test Book' },
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueAddSearchJob).toHaveBeenCalled();
});
it('creates pending request and runs search when book is unreleased but setting OFF', async () => {
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: future });
configServiceGet.mockResolvedValue('false');
prismaMock.request.create.mockResolvedValue({
id: 'request-future-off',
userId: TEST_USER_ID,
audiobookId: 'audiobook-1',
status: 'pending',
audiobook: { id: 'audiobook-1', title: 'Test Book' },
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueAddSearchJob).toHaveBeenCalled();
});
});