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:
kikootwo
2026-03-05 11:30:39 -05:00
parent 248bd5359c
commit cc8e106a2b
40 changed files with 2582 additions and 655 deletions
+39
View File
@@ -0,0 +1,39 @@
/**
* Component: Audible Categories API Route
* Documentation: documentation/features/home-sections.md
*
* Live scrape of top-level Audible categories for the home section config modal.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Audible.Categories');
/**
* GET /api/audible/categories
* Returns top-level Audible categories scraped live from audible.com/categories
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (_req: AuthenticatedRequest) => {
try {
const { getAudibleService } = await import('@/lib/integrations/audible.service');
const audibleService = getAudibleService();
const categories = await audibleService.getCategories();
return NextResponse.json({
success: true,
categories,
});
} catch (error) {
logger.error('Failed to fetch categories', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch Audible categories' },
{ status: 500 }
);
}
});
}
@@ -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 }
);
}
}
+16 -10
View File
@@ -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,
+63 -53
View File
@@ -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) });
+63 -53
View File
@@ -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) });
+202
View File
@@ -0,0 +1,202 @@
/**
* Component: User Home Sections API Route
* Documentation: documentation/features/home-sections.md
*
* Per-user configurable home page sections.
* GET returns sections + next refresh time.
* PUT saves full section config (delete-and-recreate in transaction).
*/
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.User.HomeSections');
const MAX_SECTIONS = 10;
const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const;
const SectionSchema = z.object({
sectionType: z.enum(VALID_SECTION_TYPES),
categoryId: z.string().optional().nullable(),
categoryName: z.string().optional().nullable(),
sortOrder: z.number().int().min(0),
});
const PutBodySchema = z.object({
sections: z.array(SectionSchema).max(MAX_SECTIONS),
});
/**
* Create default home sections for a new user (Popular + New Releases).
*/
async function ensureDefaultSections(userId: string) {
const existing = await prisma.userHomeSection.findMany({
where: { userId },
select: { id: true },
take: 1,
});
if (existing.length > 0) return;
await prisma.userHomeSection.createMany({
data: [
{ userId, sectionType: 'popular', sortOrder: 0 },
{ userId, sectionType: 'new_releases', sortOrder: 1 },
],
});
}
/**
* GET /api/user/home-sections
* Returns the user's configured home sections + next scheduled refresh time.
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await ensureDefaultSections(req.user.id);
const sections = await prisma.userHomeSection.findMany({
where: { userId: req.user.id },
orderBy: { sortOrder: 'asc' },
});
// Get next refresh time from scheduled jobs
let nextRefresh: string | null = null;
try {
const scheduledJob = await prisma.scheduledJob.findFirst({
where: { type: 'audible_refresh', enabled: true },
select: { nextRun: true },
});
nextRefresh = scheduledJob?.nextRun?.toISOString() || null;
} catch {
// Non-critical — just omit nextRefresh
}
return NextResponse.json({
success: true,
sections: sections.map((s) => ({
id: s.id,
sectionType: s.sectionType,
categoryId: s.categoryId,
categoryName: s.categoryName,
sortOrder: s.sortOrder,
})),
nextRefresh,
});
} catch (error) {
logger.error('Failed to get home sections', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'FetchError', message: 'Failed to fetch home sections' },
{ status: 500 }
);
}
});
}
/**
* PUT /api/user/home-sections
* Replaces all home sections for the user (delete-and-recreate in transaction).
* Validates: max 10 sections, no duplicate sections, category sections need categoryId.
*/
export async function PUT(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 { sections } = PutBodySchema.parse(body);
// Validate category sections have categoryId
for (const section of sections) {
if (section.sectionType === 'category' && !section.categoryId) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Category sections require a categoryId' },
{ status: 400 }
);
}
}
// Check for duplicate section types (only one popular, one new_releases, unique categories)
const seen = new Set<string>();
for (const section of sections) {
const key =
section.sectionType === 'category'
? `category:${section.categoryId}`
: section.sectionType;
if (seen.has(key)) {
return NextResponse.json(
{ error: 'ValidationError', message: `Duplicate section: ${key}` },
{ status: 400 }
);
}
seen.add(key);
}
const userId = req.user.id;
// Delete-and-recreate in a transaction
await prisma.$transaction(async (tx) => {
await tx.userHomeSection.deleteMany({ where: { userId } });
if (sections.length > 0) {
await tx.userHomeSection.createMany({
data: sections.map((s, idx) => ({
userId,
sectionType: s.sectionType,
categoryId: s.sectionType === 'category' ? s.categoryId : null,
categoryName: s.sectionType === 'category' ? s.categoryName : null,
sortOrder: idx,
})),
});
}
});
// Return the saved sections
const saved = await prisma.userHomeSection.findMany({
where: { userId },
orderBy: { sortOrder: 'asc' },
});
logger.info(`User ${userId} updated home sections (${saved.length} sections)`);
return NextResponse.json({
success: true,
sections: saved.map((s) => ({
id: s.id,
sectionType: s.sectionType,
categoryId: s.categoryId,
categoryName: s.categoryName,
sortOrder: s.sortOrder,
})),
});
} catch (error) {
logger.error('Failed to save home sections', {
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: 'SaveError', message: 'Failed to save home sections' },
{ status: 500 }
);
}
});
}