mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user