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
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Audiobooks.Category');
@@ -129,12 +130,15 @@ export async function GET(
const userId = currentUser?.sub || undefined;
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
+5 -1
View File
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
+5 -1
View File
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Popular');
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
// Annotate with per-user ignore status
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
audiobooks: annotatedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
+5 -1
View File
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Audiobooks.Search');
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
// Annotate with per-user ignore status
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
results: annotatedResults,
totalResults: enrichedResults.length,
page: results.page,
hasMore: results.hasMore,
+6 -2
View File
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
import { persistDedupGroups } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Authors.Books');
@@ -67,11 +68,14 @@ export async function GET(
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
// Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
return NextResponse.json({
success: true,
books: enrichedBooks,
books: annotatedBooks,
authorName: authorName.trim(),
authorAsin: asin,
totalBooks: enrichedBooks.length,
+2 -1
View File
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch });
}, { skipAutoSearch, bypassIgnore: true });
if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
ignored: { error: 'Ignored', status: 409 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json(
+6 -2
View File
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Series.Detail');
@@ -63,13 +64,16 @@ export async function GET(
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
// Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
return NextResponse.json({
success: true,
series: {
...detail,
books: enrichedBooks,
books: annotatedBooks,
},
hasMore: detail.hasMore,
page: detail.page,
@@ -0,0 +1,65 @@
/**
* Component: Ignored Audiobook Delete Route
* Documentation: documentation/features/ignored-audiobooks.md
*
* DELETE removes a single entry from the user's ignore list (un-ignore).
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.IgnoredAudiobooks');
/**
* DELETE /api/user/ignored-audiobooks/[id]
* Remove an audiobook from the user's ignore list
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
// Verify ownership before deleting
const existing = await prisma.ignoredAudiobook.findUnique({
where: { id },
});
if (!existing) {
return NextResponse.json(
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
{ status: 404 }
);
}
if (existing.userId !== req.user.id) {
return NextResponse.json(
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
{ status: 403 }
);
}
await prisma.ignoredAudiobook.delete({ where: { id } });
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to remove ignored audiobook', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
{ status: 500 }
);
}
});
}
@@ -0,0 +1,79 @@
/**
* Component: Ignored Audiobook Check Route
* Documentation: documentation/features/ignored-audiobooks.md
*
* Quick check whether a specific ASIN is ignored by the current user.
* Includes works-system expansion to catch sibling ASINs.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getSiblingAsins } from '@/lib/services/works.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
/**
* GET /api/user/ignored-audiobooks/check/[asin]
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { asin } = await params;
// Direct check
const directIgnore = await prisma.ignoredAudiobook.findUnique({
where: { userId_asin: { userId: req.user.id, asin } },
});
if (directIgnore) {
return NextResponse.json({
ignored: true,
ignoredId: directIgnore.id,
});
}
// Works-system expansion: check sibling ASINs
try {
const siblingMap = await getSiblingAsins([asin]);
const siblings = siblingMap.get(asin);
if (siblings && siblings.length > 0) {
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
where: {
userId: req.user.id,
asin: { in: siblings },
},
});
if (siblingIgnore) {
return NextResponse.json({
ignored: true,
ignoredId: siblingIgnore.id,
});
}
}
} catch {
// Works expansion is best-effort
}
return NextResponse.json({ ignored: false });
} catch (error) {
logger.error('Failed to check ignored status', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'CheckError', message: 'Failed to check ignored status' },
{ status: 500 }
);
}
});
}
@@ -0,0 +1,123 @@
/**
* Component: Ignored Audiobooks API Routes
* Documentation: documentation/features/ignored-audiobooks.md
*
* Per-user ignore list for auto-request suppression.
* GET returns the user's full ignore list; POST adds a new entry.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.IgnoredAudiobooks');
const AddIgnoredSchema = z.object({
asin: z.string().min(1).max(20),
title: z.string().min(1).max(500),
author: z.string().min(1).max(500),
coverArtUrl: z.string().optional(),
});
/**
* GET /api/user/ignored-audiobooks
* List the current user's ignored audiobooks
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ignored = await prisma.ignoredAudiobook.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({
success: true,
ignoredAudiobooks: ignored.map((item) => ({
id: item.id,
asin: item.asin,
title: item.title,
author: item.author,
coverArtUrl: item.coverArtUrl,
createdAt: item.createdAt.toISOString(),
})),
});
} catch (error) {
logger.error('Failed to list ignored audiobooks', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
{ status: 500 }
);
}
});
}
/**
* POST /api/user/ignored-audiobooks
* Add an audiobook to the user's ignore list
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const data = AddIgnoredSchema.parse(body);
// Upsert to handle duplicate gracefully
const ignored = await prisma.ignoredAudiobook.upsert({
where: {
userId_asin: { userId: req.user.id, asin: data.asin },
},
update: {}, // Already exists — no-op
create: {
userId: req.user.id,
asin: data.asin,
title: data.title,
author: data.author,
coverArtUrl: data.coverArtUrl,
},
});
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
return NextResponse.json({
success: true,
ignoredAudiobook: {
id: ignored.id,
asin: ignored.asin,
title: ignored.title,
author: ignored.author,
coverArtUrl: ignored.coverArtUrl,
createdAt: ignored.createdAt.toISOString(),
},
}, { status: 201 });
} catch (error) {
logger.error('Failed to add ignored audiobook', {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'CreateError', message: 'Failed to ignore audiobook' },
{ status: 500 }
);
}
});
}