Add BookDate card stack animations and thumbnail caching

Implements pure CSS card stack animations for BookDate recommendations, including smooth exit and advance transitions. Adds local caching of library cover thumbnails during scans, updates database schema and API to serve cached covers, and enhances BookDate to support 'favorites' scope with a book picker modal. Updates admin settings validation logic for Prowlarr, improves indexer state management, and documents new features and backend changes.
This commit is contained in:
kikootwo
2026-01-20 17:28:27 -05:00
parent 2d9ed5c76a
commit ac2ad8aac2
33 changed files with 2371 additions and 707 deletions
+125
View File
@@ -0,0 +1,125 @@
/**
* Component: BookDate Library API
* Documentation: documentation/features/bookdate.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.BookDate.Library');
/**
* GET /api/bookdate/library
* Get user's full library for book picker modal
* Returns: id, title, author, coverUrl (thumbnail)
*/
async function getLibraryBooks(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get library ID based on backend mode
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
let libraryId: string;
if (backendMode === 'audiobookshelf') {
const absLibraryId = await configService.get('audiobookshelf.library_id');
if (!absLibraryId) {
return NextResponse.json(
{ error: 'No Audiobookshelf library ID configured' },
{ status: 400 }
);
}
libraryId = absLibraryId;
} else {
// Plex mode
const plexConfig = await configService.getPlexConfig();
if (!plexConfig.libraryId) {
return NextResponse.json(
{ error: 'No Plex library ID configured' },
{ status: 400 }
);
}
libraryId = plexConfig.libraryId;
}
// Fetch ALL books from library (no limit - client handles pagination/infinite scroll)
// Join with AudibleCache to get cached cover images
const books = await prisma.plexLibrary.findMany({
where: { plexLibraryId: libraryId },
select: {
id: true,
title: true,
author: true,
asin: true, // For joining with AudibleCache
cachedLibraryCoverPath: true, // For library cached covers
},
orderBy: { addedAt: 'desc' },
});
logger.info(`Fetched ${books.length} books from library for user ${userId}`);
// Get ASINs for books that have them
const asins = books.map(b => b.asin).filter((asin): asin is string => !!asin);
// Fetch cached covers from AudibleCache (only for books with ASINs)
const cachedCovers = await prisma.audibleCache.findMany({
where: {
asin: { in: asins },
},
select: {
asin: true,
coverArtUrl: true,
},
});
// Create ASIN -> coverUrl map
const coverMap = new Map<string, string>();
cachedCovers.forEach(cache => {
if (cache.coverArtUrl) {
coverMap.set(cache.asin, cache.coverArtUrl);
}
});
logger.info(`Found ${coverMap.size} cached covers out of ${asins.length} books with ASINs`);
// Map books with their covers (priority: library cache > Audible cache > null)
return NextResponse.json({
books: books.map(book => {
let coverUrl: string | null = null;
// Priority 1: Library cached cover (most books should have this)
if (book.cachedLibraryCoverPath) {
const filename = book.cachedLibraryCoverPath.split('/').pop();
coverUrl = `/api/cache/library/${filename}`;
}
// Priority 2: Audible cache (fallback for books with ASIN but no library cache)
else if (book.asin && coverMap.has(book.asin)) {
coverUrl = coverMap.get(book.asin)!;
}
// Priority 3: null (show placeholder)
return {
id: book.id,
title: book.title,
author: book.author,
coverUrl,
};
}),
});
} catch (error: any) {
logger.error('Get library books error', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: error.message || 'Failed to fetch library books' },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, getLibraryBooks);
}
+29 -3
View File
@@ -24,6 +24,7 @@ async function getPreferences(req: AuthenticatedRequest) {
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateFavoriteBookIds: true,
bookDateCustomPrompt: true,
bookDateOnboardingComplete: true,
},
@@ -49,6 +50,7 @@ async function getPreferences(req: AuthenticatedRequest) {
return NextResponse.json({
libraryScope: effectiveScope,
favoriteBookIds: user.bookDateFavoriteBookIds ? JSON.parse(user.bookDateFavoriteBookIds) : [],
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: user.bookDateOnboardingComplete || false,
backendCapabilities: {
@@ -75,12 +77,28 @@ async function updatePreferences(req: AuthenticatedRequest) {
// Parse request body
const body = await req.json();
const { libraryScope, customPrompt, onboardingComplete } = body;
const { libraryScope, favoriteBookIds, customPrompt, onboardingComplete } = body;
// Validate library scope
if (libraryScope && !['full', 'rated'].includes(libraryScope)) {
if (libraryScope && !['full', 'rated', 'favorites'].includes(libraryScope)) {
return NextResponse.json(
{ error: 'Invalid library scope. Must be "full" or "rated"' },
{ error: 'Invalid library scope. Must be "full", "rated", or "favorites"' },
{ status: 400 }
);
}
// Validate favorites scope requirements
if (libraryScope === 'favorites' && (!favoriteBookIds || favoriteBookIds.length === 0)) {
return NextResponse.json(
{ error: 'Favorites scope requires at least 1 favorite book selected' },
{ status: 400 }
);
}
// Validate favorite books limit
if (favoriteBookIds && favoriteBookIds.length > 25) {
return NextResponse.json(
{ error: 'Maximum 25 favorite books allowed' },
{ status: 400 }
);
}
@@ -110,6 +128,12 @@ async function updatePreferences(req: AuthenticatedRequest) {
if (libraryScope !== undefined) {
updateData.bookDateLibraryScope = libraryScope || 'full';
}
if (favoriteBookIds !== undefined) {
// Store as JSON string
updateData.bookDateFavoriteBookIds = favoriteBookIds && favoriteBookIds.length > 0
? JSON.stringify(favoriteBookIds)
: null;
}
if (customPrompt !== undefined) {
// Normalize empty strings to null for consistency
const normalizedPrompt = (typeof customPrompt === 'string' && customPrompt.trim()) ? customPrompt.trim() : null;
@@ -125,6 +149,7 @@ async function updatePreferences(req: AuthenticatedRequest) {
data: updateData,
select: {
bookDateLibraryScope: true,
bookDateFavoriteBookIds: true,
bookDateCustomPrompt: true,
bookDateOnboardingComplete: true,
},
@@ -133,6 +158,7 @@ async function updatePreferences(req: AuthenticatedRequest) {
return NextResponse.json({
success: true,
libraryScope: updatedUser.bookDateLibraryScope || 'full',
favoriteBookIds: updatedUser.bookDateFavoriteBookIds ? JSON.parse(updatedUser.bookDateFavoriteBookIds) : [],
customPrompt: updatedUser.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: updatedUser.bookDateOnboardingComplete || false,
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Component: Library Cover Cache API Route
* Documentation: documentation/features/library-thumbnail-cache.md
*/
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.LibraryCovers');
const LIBRARY_CACHE_DIR = '/app/cache/library';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename (prevent directory traversal)
if (!filename || filename.includes('..') || filename.includes('/')) {
return NextResponse.json(
{ error: 'Invalid filename' },
{ status: 400 }
);
}
const filePath = path.join(LIBRARY_CACHE_DIR, filename);
// Check if file exists
try {
await fs.access(filePath);
} catch {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
);
}
// Read the file
const fileBuffer = await fs.readFile(filePath);
// Determine content type based on extension
const ext = path.extname(filename).toLowerCase();
const contentTypeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
const contentType = contentTypeMap[ext] || 'application/octet-stream';
// Return the image with appropriate headers
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
},
});
} catch (error) {
logger.error('Error serving library cover', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}