mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add per-user home sections & unified Audible cache
Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Category Audiobooks API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
|
||||
* with the same enrichment pattern as popular/new-releases routes.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ categoryId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { categoryId } = await params;
|
||||
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';
|
||||
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Get excluded ASINs when hideAvailable
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
// Query AudibleCacheCategory joined with AudibleCache
|
||||
const whereClause: any = { categoryId };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
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 (totalCount === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: [],
|
||||
count: 0,
|
||||
totalCount: 0,
|
||||
page,
|
||||
totalPages: 0,
|
||||
hasMore: false,
|
||||
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
|
||||
});
|
||||
}
|
||||
|
||||
// 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[];
|
||||
|
||||
// Enrich with library matching and request status
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get category audiobooks', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
* Component: Audiobook Covers API Route
|
||||
* Documentation: documentation/frontend/pages/login.md
|
||||
*
|
||||
* Serves random popular audiobook covers for login page floating animations
|
||||
* Serves random popular audiobook covers for login page floating animations.
|
||||
* Queries AudibleCacheCategory with '__popular__' categoryId for cover sources.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
|
||||
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Fetch all popular audiobooks with covers (up to 200)
|
||||
// Get popular ASINs from category table (up to 200)
|
||||
const categoryEntries = await prisma.audibleCacheCategory.findMany({
|
||||
where: { categoryId: POPULAR_CATEGORY_ID },
|
||||
orderBy: { rank: 'asc' },
|
||||
take: 200,
|
||||
select: { asin: true },
|
||||
});
|
||||
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
|
||||
// Fetch cover data from AudibleCache for popular ASINs with cached covers
|
||||
const audiobooks = await prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
cachedCoverPath: {
|
||||
not: null,
|
||||
},
|
||||
asin: { in: asins },
|
||||
cachedCoverPath: { not: null },
|
||||
},
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
take: 200,
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Component: New Releases API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves new release audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves new release audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* New releases are stored with categoryId '__new_releases__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -10,12 +11,13 @@ 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 { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/new-releases?page=1&limit=20
|
||||
* Get new release audiobooks from audible_cache with pagination
|
||||
* Get new release audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isNewRelease: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query audible_cache for new release audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
// Query AudibleCacheCategory for new release audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: whereClause,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -95,30 +79,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
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[]) || [],
|
||||
};
|
||||
// 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;
|
||||
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Component: Popular Audiobooks API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves popular audiobooks from audible_cache with real-time Plex matching
|
||||
* 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';
|
||||
@@ -10,12 +11,13 @@ 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 { 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 audible_cache with pagination
|
||||
* Get popular audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isPopular: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query audible_cache for popular audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
// Query AudibleCacheCategory for popular audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: whereClause,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -95,30 +79,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
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[]) || [],
|
||||
};
|
||||
// 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;
|
||||
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
Reference in New Issue
Block a user