mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
09cff5b68d
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.
167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
/**
|
|
* Component: Popular Audiobooks API Route
|
|
* Documentation: documentation/integrations/audible.md
|
|
*
|
|
* Serves popular audiobooks from AudibleCacheCategory with real-time library matching.
|
|
* Popular books are stored with categoryId '__popular__' in the unified category table.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
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');
|
|
|
|
/**
|
|
* GET /api/audiobooks/popular?page=1&limit=20
|
|
* Get popular audiobooks from AudibleCacheCategory with pagination
|
|
*
|
|
* Real-time matching against plex_library determines availability.
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const searchParams = request.nextUrl.searchParams;
|
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
|
|
|
// Validate pagination parameters
|
|
if (page < 1 || limit < 1 || limit > 100) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'ValidationError',
|
|
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const skip = (page - 1) * limit;
|
|
|
|
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
|
let excludedAsins: string[] = [];
|
|
if (hideAvailable) {
|
|
const availableSet = await getAvailableAsins();
|
|
excludedAsins = [...availableSet];
|
|
}
|
|
|
|
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
|
|
if (excludedAsins.length > 0) {
|
|
whereClause.asin = { notIn: excludedAsins };
|
|
}
|
|
|
|
// Query AudibleCacheCategory for popular audiobooks
|
|
const [categoryEntries, totalCount] = await Promise.all([
|
|
prisma.audibleCacheCategory.findMany({
|
|
where: whereClause,
|
|
orderBy: { rank: 'asc' },
|
|
skip,
|
|
take: limit,
|
|
select: { asin: true, rank: true },
|
|
}),
|
|
prisma.audibleCacheCategory.count({ where: whereClause }),
|
|
]);
|
|
|
|
// If no data found, return helpful message
|
|
if (totalCount === 0) {
|
|
return NextResponse.json({
|
|
success: true,
|
|
audiobooks: [],
|
|
count: 0,
|
|
totalCount: 0,
|
|
page,
|
|
totalPages: 0,
|
|
hasMore: false,
|
|
message: 'No popular audiobooks found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
|
|
});
|
|
}
|
|
|
|
// Fetch full metadata from AudibleCache for these ASINs
|
|
const asins = categoryEntries.map((e) => e.asin);
|
|
const cacheEntries = await prisma.audibleCache.findMany({
|
|
where: { asin: { in: asins } },
|
|
select: {
|
|
asin: true,
|
|
title: true,
|
|
author: true,
|
|
narrator: true,
|
|
description: true,
|
|
coverArtUrl: true,
|
|
cachedCoverPath: true,
|
|
durationMinutes: true,
|
|
releaseDate: true,
|
|
rating: true,
|
|
genres: true,
|
|
lastSyncedAt: true,
|
|
},
|
|
});
|
|
|
|
// Build a map for ordering by rank
|
|
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
|
|
|
// Transform to matcher input format, preserving rank order
|
|
const audibleBooks = categoryEntries
|
|
.map((entry) => {
|
|
const book = cacheMap.get(entry.asin);
|
|
if (!book) return null;
|
|
|
|
let coverUrl = book.coverArtUrl || undefined;
|
|
if (book.cachedCoverPath) {
|
|
const filename = book.cachedCoverPath.split('/').pop();
|
|
coverUrl = `/api/cache/thumbnails/${filename}`;
|
|
}
|
|
|
|
return {
|
|
asin: book.asin,
|
|
title: book.title,
|
|
author: book.author,
|
|
narrator: book.narrator || undefined,
|
|
description: book.description || undefined,
|
|
coverArtUrl: coverUrl,
|
|
durationMinutes: book.durationMinutes || undefined,
|
|
releaseDate: book.releaseDate?.toISOString() || undefined,
|
|
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
|
genres: (book.genres as string[]) || [],
|
|
};
|
|
})
|
|
.filter(Boolean) as any[];
|
|
|
|
// Get current user (optional - for request status enrichment)
|
|
const currentUser = getCurrentUser(request);
|
|
const userId = currentUser?.sub || undefined;
|
|
|
|
// 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: annotatedAudiobooks,
|
|
count: enrichedAudiobooks.length,
|
|
totalCount,
|
|
page,
|
|
totalPages,
|
|
hasMore,
|
|
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
|
return NextResponse.json(
|
|
{
|
|
error: 'FetchError',
|
|
message: 'Failed to fetch popular audiobooks from database',
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|