Add per-user ignored audiobooks feature

Introduce a per-user "ignored audiobooks" feature to suppress auto-requests. Changes include:

- Database: add Prisma model IgnoredAudiobook and SQL migration to create ignored_audiobooks table with indexes and FK to users.
- Backend: new API routes to list, add, delete, and check ignored audiobooks (/api/user/ignored-audiobooks, /check/:asin, /:id). Add annotateWithIgnoreStatus utility and integrate it into multiple audiobook list endpoints (popular, new-releases, category, search, authors, series).
- Request creator: add ignore-list check (with sibling-ASIN expansion) and a bypassIgnore option for manual requests; return an 'ignored' reason when blocked.
- Frontend: hooks (useIsIgnored, useToggleIgnore, useIgnoredList) and UI updates — AudiobookCard shows an "Ignored" indicator and AudiobookDetailsModal adds an ignore toggle and propagates local state changes.
- Misc: adjust deduplication duration tolerance (to 5% / min 10 minutes), tweak SWR refresh intervals for shelves/syncing, and small logging/info updates.
- Tests: add unit tests for request-creator ignore logic and update existing tests/mocks to account for ignore annotation; extend prisma test helper with ignoredAudiobook mock.

This commit implements the ignore-list end-to-end (DB, server, client, and tests) so users can ignore specific ASINs and have auto-request flows respect that preference.
This commit is contained in:
kikootwo
2026-03-11 11:56:35 -04:00
parent da7ad7cac1
commit 09cff5b68d
25 changed files with 873 additions and 35 deletions
@@ -27,6 +27,13 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
enrichAudiobooksWithMatches: enrichMock,
}));
// Mock ignore status annotation — pass-through that adds isIgnored: false
vi.mock('@/lib/utils/ignored-audiobooks', () => ({
annotateWithIgnoreStatus: vi.fn(async (books: any[]) =>
books.map((b: any) => ({ ...b, isIgnored: false }))
),
}));
vi.mock('@/lib/middleware/auth', () => ({
getCurrentUser: currentUserMock,
}));
+1
View File
@@ -57,6 +57,7 @@ export const createPrismaMock = () => ({
watchedAuthor: createModelMock(),
userHomeSection: createModelMock(),
audibleCacheCategory: createModelMock(),
ignoredAudiobook: createModelMock(),
$queryRaw: vi.fn(),
$transaction: vi.fn(),
$disconnect: vi.fn(),
@@ -0,0 +1,200 @@
/**
* Component: Request Creator Ignore Tests
* Documentation: documentation/features/ignored-audiobooks.md
*
* Tests the per-user ignore list check in createRequestForUser,
* including direct ASIN match, works-system sibling expansion,
* and the bypassIgnore option.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/utils/logger', () => ({
RMABLogger: {
create: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));
// Mock findPlexMatch to return null (not in library)
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: vi.fn().mockResolvedValue(null),
}));
// Mock AudibleService
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
// Mock job queue
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => ({
addSearchJob: vi.fn().mockResolvedValue(undefined),
addNotificationJob: vi.fn().mockResolvedValue(undefined),
}),
}));
// Mock getSiblingAsins from works.service
const mockGetSiblingAsins = vi.fn().mockResolvedValue(new Map());
const mockSeedAsin = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/services/works.service', () => ({
getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args),
seedAsin: (...args: any[]) => mockSeedAsin(...args),
}));
const TEST_AUDIOBOOK = {
asin: 'B00TEST001',
title: 'Test Book',
author: 'Test Author',
};
const TEST_USER_ID = 'user-123';
describe('createRequestForUser — ignore list', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: no existing requests, no library matches
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.request.create.mockResolvedValue({
id: 'request-1',
userId: TEST_USER_ID,
audiobookId: 'audiobook-1',
status: 'pending',
audiobook: { id: 'audiobook-1', title: 'Test Book' },
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
});
prismaMock.user.findUnique.mockResolvedValue({
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser',
});
// Default: not ignored
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
mockGetSiblingAsins.mockResolvedValue(new Map());
mockSeedAsin.mockResolvedValue(undefined);
});
it('blocks auto-request when ASIN is directly ignored', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
id: 'ignored-1',
userId: TEST_USER_ID,
asin: TEST_AUDIOBOOK.asin,
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.reason).toBe('ignored');
expect(result.message).toContain('ignore list');
}
// Should NOT create a request
expect(prismaMock.request.create).not.toHaveBeenCalled();
});
it('blocks auto-request when sibling ASIN is ignored', async () => {
// Direct ASIN not ignored
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
// But a sibling is ignored
mockGetSiblingAsins.mockResolvedValue(new Map([
[TEST_AUDIOBOOK.asin, ['B00SIBLING']],
]));
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue({
id: 'ignored-sibling',
userId: TEST_USER_ID,
asin: 'B00SIBLING',
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.reason).toBe('ignored');
}
expect(prismaMock.request.create).not.toHaveBeenCalled();
});
it('allows manual request with bypassIgnore even when ignored', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
id: 'ignored-1',
userId: TEST_USER_ID,
asin: TEST_AUDIOBOOK.asin,
});
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK, {
bypassIgnore: true,
});
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalled();
// Should NOT have even checked the ignore list
expect(prismaMock.ignoredAudiobook.findUnique).not.toHaveBeenCalled();
});
it('allows request when ASIN is not ignored', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
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).toHaveBeenCalled();
});
it('falls through gracefully when works expansion fails', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
mockGetSiblingAsins.mockRejectedValue(new Error('DB error'));
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
// Should still succeed since direct check passed and expansion is best-effort
expect(result.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalled();
});
it('does not check siblings when no sibling ASINs exist', async () => {
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
mockGetSiblingAsins.mockResolvedValue(new Map());
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
expect(result.success).toBe(true);
// Should not have queried findFirst for sibling check since map was empty
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
});
});
+18 -16
View File
@@ -137,16 +137,18 @@ describe('areDurationsCompatible', () => {
expect(areDurationsCompatible(600, 600)).toBe(true);
});
it('uses 1% of longer duration as tolerance for long books', () => {
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
it('uses 5% of longer duration as tolerance for long books', () => {
// tolerance = max(longer*0.05, 10). When b > a, longer = b, so threshold shifts.
// 2400 vs 2526: longer=2526, tol=126.3, diff=126 → true
expect(areDurationsCompatible(2400, 2526)).toBe(true);
// 2400 vs 2527: longer=2527, tol=126.35, diff=127 → false
expect(areDurationsCompatible(2400, 2527)).toBe(false);
});
it('uses 5-minute minimum tolerance for short books', () => {
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
it('uses 10-minute minimum tolerance for short books', () => {
// Two 2-hour books (120 min): tolerance = max(120*0.05, 10) = max(6, 10) = 10 min
expect(areDurationsCompatible(120, 130)).toBe(true); // exactly at 10-min minimum
expect(areDurationsCompatible(120, 131)).toBe(false); // just over
});
it('keeps abridged vs unabridged separate (large duration gap)', () => {
@@ -155,10 +157,10 @@ describe('areDurationsCompatible', () => {
});
it('symmetry: order does not matter', () => {
expect(areDurationsCompatible(2400, 2424)).toBe(true);
expect(areDurationsCompatible(2424, 2400)).toBe(true);
expect(areDurationsCompatible(120, 126)).toBe(false);
expect(areDurationsCompatible(126, 120)).toBe(false);
expect(areDurationsCompatible(2400, 2526)).toBe(true);
expect(areDurationsCompatible(2526, 2400)).toBe(true);
expect(areDurationsCompatible(120, 131)).toBe(false);
expect(areDurationsCompatible(131, 120)).toBe(false);
});
});
@@ -305,17 +307,17 @@ describe('deduplicateAudiobooks', () => {
});
it('uses percentage tolerance for very long audiobooks', () => {
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
// tolerance = max(longer*0.05, 10). 2400 vs 2526: longer=2526, tol=126.3, diff=126 → same
const books = [
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2526 }),
];
expect(deduplicateAudiobooks(books)).toHaveLength(1);
// Beyond tolerance
// Beyond tolerance: 2400 vs 2600: longer=2600, tol=130, diff=200 → different
const booksFar = [
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2600 }),
];
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
});