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
+10 -1
View File
@@ -163,6 +163,7 @@ export const validateAuthSettings = (settings: Settings): { valid: boolean; mess
export const getTabValidation = (
activeTab: SettingsTab,
settings: Settings,
originalSettings: Settings | null,
validated: {
plex: boolean;
audiobookshelf: boolean;
@@ -179,7 +180,15 @@ export const getTabValidation = (
case 'auth':
return validated.oidc || validated.registration;
case 'prowlarr':
return validated.prowlarr;
// Only require validation if URL or API key changed
// If only indexers/flags changed, allow saving without test
if (!originalSettings) return validated.prowlarr;
const prowlarrConnectionChanged =
settings.prowlarr.url !== originalSettings.prowlarr.url ||
settings.prowlarr.apiKey !== originalSettings.prowlarr.apiKey;
return prowlarrConnectionChanged ? validated.prowlarr : true;
case 'download':
return validated.download;
case 'paths':
+31 -4
View File
@@ -49,7 +49,9 @@ export default function AdminSettings() {
// Indexer-specific state (used by IndexersTab)
const [configuredIndexers, setConfiguredIndexers] = useState<SavedIndexerConfig[]>([]);
const [originalConfiguredIndexers, setOriginalConfiguredIndexers] = useState<SavedIndexerConfig[]>([]);
const [flagConfigs, setFlagConfigs] = useState<IndexerFlagConfig[]>([]);
const [originalFlagConfigs, setOriginalFlagConfigs] = useState<IndexerFlagConfig[]>([]);
// Initial data fetch
useEffect(() => {
@@ -89,7 +91,9 @@ export default function AdminSettings() {
const response = await fetchWithAuth('/api/admin/settings/prowlarr/indexers');
if (response.ok) {
const data = await response.json();
setFlagConfigs(data.flagConfigs || []);
const flags = data.flagConfigs || [];
setFlagConfigs(flags);
setOriginalFlagConfigs(JSON.parse(JSON.stringify(flags)));
// Extract configured indexers (enabled ones)
const configured = (data.indexers || [])
@@ -103,6 +107,7 @@ export default function AdminSettings() {
categories: idx.categories || [3030],
}));
setConfiguredIndexers(configured);
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configured)));
} else {
console.error('Failed to fetch indexers:', response.status);
if (force) {
@@ -139,6 +144,13 @@ export default function AdminSettings() {
await saveTabSettings(activeTab, settings, configuredIndexers, flagConfigs);
setMessage({ type: 'success', text: 'Settings saved successfully!' });
setOriginalSettings(JSON.parse(JSON.stringify(settings)));
// Also update original indexers and flag configs when saving prowlarr tab
if (activeTab === 'prowlarr') {
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configuredIndexers)));
setOriginalFlagConfigs(JSON.parse(JSON.stringify(flagConfigs)));
}
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage({
@@ -161,8 +173,21 @@ export default function AdminSettings() {
// Dynamic tabs, validation, and change detection
const tabs = getTabs(settings.backendMode);
const currentTabValidation = getTabValidation(activeTab, settings, validated);
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings);
const currentTabValidation = getTabValidation(activeTab, settings, originalSettings, validated);
// Check for unsaved changes in settings and indexer-specific state
const hasUnsavedChanges = (() => {
const settingsChanged = JSON.stringify(settings) !== JSON.stringify(originalSettings);
// For prowlarr tab, also check indexers and flag configs
if (activeTab === 'prowlarr') {
const indexersChanged = JSON.stringify(configuredIndexers) !== JSON.stringify(originalConfiguredIndexers);
const flagConfigsChanged = JSON.stringify(flagConfigs) !== JSON.stringify(originalFlagConfigs);
return settingsChanged || indexersChanged || flagConfigsChanged;
}
return settingsChanged;
})();
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -315,7 +340,9 @@ export default function AdminSettings() {
</Button>
{!currentTabValidation && hasUnsavedChanges && (
<p className="text-sm text-gray-500 dark:text-gray-400 self-center">
Please test the connection before saving
{activeTab === 'prowlarr'
? 'Please test the Prowlarr connection before saving'
: 'Please test the connection before saving'}
</p>
)}
</div>
+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 }
);
}
}
+18 -13
View File
@@ -8,7 +8,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { RecommendationCard } from '@/components/bookdate/RecommendationCard';
import { CardStack } from '@/components/bookdate/CardStack';
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';
import { SettingsWidget } from '@/components/bookdate/SettingsWidget';
@@ -150,13 +150,8 @@ export default function BookDatePage() {
}, 3000);
}
// Move to next recommendation
setCurrentIndex(currentIndex + 1);
// Check if we need to load more recommendations
if (currentIndex + 1 >= recommendations.length) {
// At the end - could auto-load more or show empty state
}
// Note: currentIndex is now incremented in handleSwipeComplete
// after animations finish
} catch (error) {
console.error('Swipe error:', error);
@@ -164,6 +159,16 @@ export default function BookDatePage() {
}
};
const handleSwipeComplete = () => {
// Increment currentIndex after animations complete
setCurrentIndex((prev) => prev + 1);
// Check if we need to load more recommendations
if (currentIndex + 1 >= recommendations.length) {
// At the end - could auto-load more or show empty state
}
};
const handleUndo = async () => {
if (!lastSwipe || lastSwipe.action === 'right') {
return;
@@ -323,8 +328,6 @@ export default function BookDatePage() {
);
}
const currentRec = recommendations[currentIndex];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
@@ -347,10 +350,12 @@ export default function BookDatePage() {
{currentIndex + 1} / {recommendations.length}
</div>
{/* Recommendation card */}
<RecommendationCard
recommendation={currentRec}
{/* Card Stack */}
<CardStack
recommendations={recommendations}
currentIndex={currentIndex}
onSwipe={handleSwipe}
onSwipeComplete={handleSwipeComplete}
/>
{/* Undo button */}
+131
View File
@@ -39,3 +39,134 @@ body {
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
/* BookDate Card Stack Animations */
/* Exit animations - card swipes away */
@keyframes card-exit-left {
0% {
transform: translate(0, 0) rotate(0deg);
opacity: 1;
}
100% {
transform: translate(-150%, 50px) rotate(-25deg);
opacity: 0;
}
}
@keyframes card-exit-right {
0% {
transform: translate(0, 0) rotate(0deg);
opacity: 1;
}
100% {
transform: translate(150%, 50px) rotate(25deg);
opacity: 0;
}
}
@keyframes card-exit-up {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(0, -120%) scale(0.8);
opacity: 0;
}
}
/* Advance animations - cards move forward in stack */
@keyframes card-advance-to-top {
0% {
transform: scale(0.95) translateY(-12px);
opacity: 0.95;
}
100% {
transform: scale(1) translateY(0);
opacity: 1;
}
}
@keyframes card-advance-to-middle {
0% {
transform: scale(0.90) translateY(-24px);
opacity: 0.90;
}
100% {
transform: scale(0.95) translateY(-12px);
opacity: 0.95;
}
}
/* Enter animation - new card enters from bottom of stack */
@keyframes card-enter {
0% {
transform: scale(0.85) translateY(-36px);
opacity: 0;
}
100% {
transform: scale(0.90) translateY(-24px);
opacity: 0.90;
}
}
/* Animation classes */
.animate-exit-left {
animation: card-exit-left 400ms ease-in-out forwards;
}
.animate-exit-right {
animation: card-exit-right 400ms ease-in-out forwards;
}
.animate-exit-up {
animation: card-exit-up 400ms ease-in-out forwards;
}
.animate-advance-to-top {
animation: card-advance-to-top 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.animate-advance-to-middle {
animation: card-advance-to-middle 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.animate-enter {
animation: card-enter 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* Stack positioning classes */
.card-stack-position-0 {
z-index: 50;
transform: scale(1) translateY(0);
opacity: 1;
pointer-events: auto;
}
.card-stack-position-1 {
z-index: 40;
transform: scale(0.95) translateY(-12px);
opacity: 0.95;
pointer-events: none;
}
.card-stack-position-2 {
z-index: 30;
transform: scale(0.90) translateY(-24px);
opacity: 0.90;
pointer-events: none;
}
/* Performance optimizations */
.card-stack-container {
perspective: 1000px;
transform-style: preserve-3d;
}
.card-stack-item {
will-change: transform, opacity;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transform-origin: center center;
}
+361
View File
@@ -0,0 +1,361 @@
/**
* Component: BookDate Book Picker Modal
* Documentation: documentation/features/bookdate.md
*/
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
interface BookPickerModalProps {
isOpen: boolean;
onClose: () => void;
selectedIds: string[];
onConfirm: (selectedIds: string[]) => void;
maxSelection: number;
}
interface LibraryBook {
id: string;
title: string;
author: string;
coverUrl?: string | null;
}
export function BookPickerModal({
isOpen,
onClose,
selectedIds,
onConfirm,
maxSelection,
}: BookPickerModalProps) {
const [books, setBooks] = useState<LibraryBook[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [localSelectedIds, setLocalSelectedIds] = useState<string[]>(selectedIds);
// Infinite scroll state
const [displayedCount, setDisplayedCount] = useState(100); // Start with 100 books
const observerTarget = useRef<HTMLDivElement>(null);
// Load library books when modal opens
useEffect(() => {
if (isOpen) {
loadLibraryBooks();
setLocalSelectedIds(selectedIds); // Reset to initial selection when reopening
setDisplayedCount(100); // Reset displayed count
setSearchQuery(''); // Reset search
}
}, [isOpen]);
const loadLibraryBooks = async () => {
setLoading(true);
setError(null);
try {
const accessToken = localStorage.getItem('accessToken');
const response = await fetch('/api/bookdate/library', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to load library books');
}
const data = await response.json();
setBooks(data.books || []);
} catch (error: any) {
console.error('Load library books error:', error);
setError(error.message || 'Failed to load library books');
} finally {
setLoading(false);
}
};
const toggleBook = (bookId: string) => {
setLocalSelectedIds(prev => {
if (prev.includes(bookId)) {
// Deselect
return prev.filter(id => id !== bookId);
} else {
// Select (only if under max)
if (prev.length < maxSelection) {
return [...prev, bookId];
}
return prev; // Already at max
}
});
};
// Reset displayed count when search query changes
const handleSearchChange = (query: string) => {
setSearchQuery(query);
setDisplayedCount(100); // Reset to show first 100 results
};
const handleConfirm = () => {
onConfirm(localSelectedIds);
onClose();
};
const handleCancel = () => {
setLocalSelectedIds(selectedIds); // Reset to original
onClose();
};
// Filter books by search query
const filteredBooks = books.filter(book => {
const query = searchQuery.toLowerCase();
return (
book.title.toLowerCase().includes(query) ||
book.author.toLowerCase().includes(query)
);
});
// Only display a subset for performance (infinite scroll)
const displayedBooks = filteredBooks.slice(0, displayedCount);
const hasMore = displayedCount < filteredBooks.length;
const isMaxReached = localSelectedIds.length >= maxSelection;
// Infinite scroll observer
useEffect(() => {
const currentFilteredLength = filteredBooks.length;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading) {
// Load more books when bottom sentinel is visible
setDisplayedCount(prev => Math.min(prev + 100, currentFilteredLength));
}
},
{ threshold: 0.1 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loading, filteredBooks.length]); // Re-run when loading state or filtered length changes
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 transition-opacity"
onClick={handleCancel}
/>
{/* Modal */}
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl bg-white dark:bg-gray-800 rounded-xl shadow-2xl z-50 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Select Your Favorite Books
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Choose up to {maxSelection} books that represent your favorites
</p>
</div>
<button
onClick={handleCancel}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl leading-none"
>
×
</button>
</div>
{/* Selection Counter */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className={`text-sm font-medium ${isMaxReached ? 'text-orange-600 dark:text-orange-400' : 'text-blue-600 dark:text-blue-400'}`}>
{localSelectedIds.length} / {maxSelection} selected
{isMaxReached && (
<span className="ml-2 text-xs text-orange-600 dark:text-orange-400">
(Maximum reached)
</span>
)}
</div>
{localSelectedIds.length > 0 && (
<button
onClick={() => setLocalSelectedIds([])}
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 font-medium px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
Clear Selection
</button>
)}
</div>
{/* Search Bar */}
<input
type="text"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search books..."
className="w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 text-sm"
/>
</div>
</div>
{/* Books Grid */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : error ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300">
{error}
</div>
) : filteredBooks.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{searchQuery ? 'No books match your search' : 'No books in your library'}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{displayedBooks.map((book, index) => {
const isSelected = localSelectedIds.includes(book.id);
const isDisabled = !isSelected && isMaxReached;
return (
<button
key={book.id}
onClick={() => !isDisabled && toggleBook(book.id)}
disabled={isDisabled}
className={`group relative aspect-[2/3] rounded-lg overflow-hidden transition-all duration-200 ${
isSelected
? 'ring-4 ring-blue-500 shadow-lg scale-105'
: isDisabled
? 'opacity-40 cursor-not-allowed'
: 'hover:scale-105 hover:shadow-md'
}`}
style={{
animationDelay: `${index * 20}ms`,
animation: 'fadeIn 0.3s ease-out forwards',
}}
>
{/* Cover Image or Text Placeholder */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
{book.coverUrl ? (
<img
src={book.coverUrl}
alt={book.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center p-3">
<div className="text-center">
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 line-clamp-4 mb-1">
{book.title}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{book.author}
</div>
</div>
</div>
)}
</div>
{/* Selection Overlay */}
{isSelected && (
<div className="absolute inset-0 bg-blue-600/20 flex items-center justify-center">
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
)}
{/* Book Info on Hover */}
{!isSelected && !isDisabled && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-white text-xs font-medium line-clamp-2">
{book.title}
</div>
<div className="text-white/80 text-xs line-clamp-1 mt-1">
{book.author}
</div>
</div>
)}
</button>
);
})}
</div>
)}
{/* Infinite scroll sentinel */}
{!loading && !error && hasMore && (
<div ref={observerTarget} className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{/* Show count info */}
{!loading && !error && filteredBooks.length > 0 && (
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
Showing {displayedBooks.length} of {filteredBooks.length} books
{filteredBooks.length !== books.length && ` (filtered from ${books.length} total)`}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-3">
<button
onClick={handleCancel}
className="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={localSelectedIds.length === 0}
className="flex-1 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium transition-colors"
>
Confirm Selection ({localSelectedIds.length})
</button>
</div>
</div>
</div>
{/* Fade-in animation */}
<style jsx>{`
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</>
);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Component: BookDate Card Stack
* Documentation: documentation/features/bookdate-animations.md
*/
'use client';
import { useState, useCallback, useEffect } from 'react';
import { RecommendationCard } from './RecommendationCard';
interface CardStackProps {
recommendations: any[];
currentIndex: number;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
onSwipeComplete: () => void;
}
export function CardStack({
recommendations,
currentIndex,
onSwipe,
onSwipeComplete,
}: CardStackProps) {
const [isExiting, setIsExiting] = useState(false);
const [exitDirection, setExitDirection] = useState<'left' | 'right' | 'up' | null>(null);
const [isAdvancing, setIsAdvancing] = useState(false);
// Reset animation states when currentIndex changes externally (e.g., undo)
useEffect(() => {
setIsExiting(false);
setExitDirection(null);
setIsAdvancing(false);
}, [currentIndex]);
const handleSwipeStart = useCallback(
(action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => {
// Prevent swipes during animation
if (isExiting || isAdvancing) {
return;
}
// Start exit animation
setIsExiting(true);
setExitDirection(action);
// Call parent's onSwipe (for API call)
onSwipe(action, markedAsKnown);
// Wait for exit animation to complete (400ms)
setTimeout(() => {
setIsExiting(false);
setExitDirection(null);
// Start advance animation
setIsAdvancing(true);
// Wait for advance animation to complete (350ms)
setTimeout(() => {
setIsAdvancing(false);
// Notify parent that animations are complete
onSwipeComplete();
}, 350);
}, 400);
},
[isExiting, isAdvancing, onSwipe, onSwipeComplete]
);
// Get up to 3 cards to display
const visibleCards = [];
if (isAdvancing) {
// During advance, skip the card that just exited (at currentIndex)
// Show cards at indices: currentIndex+1, currentIndex+2, currentIndex+3
for (let i = 0; i < 3; i++) {
const index = currentIndex + 1 + i;
if (index < recommendations.length) {
visibleCards.push({
recommendation: recommendations[index],
index,
stackPosition: i, // Target position (0, 1, 2)
fromPosition: i + 1, // Source position for animation (1, 2, 3)
});
}
}
} else {
// Normal rendering: show current card and next 2
for (let i = 0; i < 3; i++) {
const index = currentIndex + i;
if (index < recommendations.length) {
visibleCards.push({
recommendation: recommendations[index],
index,
stackPosition: i,
});
}
}
}
// If we have no cards, return null
if (visibleCards.length === 0) {
return null;
}
return (
<div className="card-stack-container relative w-full max-w-md h-[calc(80vh)] md:h-[calc(85vh)]">
{visibleCards.map((card, arrayIndex) => {
const isTopCard = card.stackPosition === 0;
const isExitingCard = isTopCard && isExiting;
// Determine animation class
let animationClass = '';
if (isExitingCard && exitDirection) {
animationClass = `animate-exit-${exitDirection}`;
} else if (isAdvancing && card.fromPosition !== undefined) {
// Cards are advancing from their previous position
if (card.fromPosition === 1) {
animationClass = 'animate-advance-to-top'; // 1 → 0
} else if (card.fromPosition === 2) {
animationClass = 'animate-advance-to-middle'; // 2 → 1
} else if (card.fromPosition === 3) {
animationClass = 'animate-enter'; // 3 → 2 (new card)
}
}
// Determine static position class (when not animating)
const positionClass = !animationClass
? `card-stack-position-${card.stackPosition}`
: '';
return (
<div
key={card.index}
className={`card-stack-item absolute inset-0 ${positionClass} ${animationClass}`}
style={{
// Ensure proper stacking even without animation
zIndex: 50 - card.stackPosition * 10,
}}
>
<RecommendationCard
recommendation={card.recommendation}
onSwipe={handleSwipeStart}
stackPosition={card.stackPosition}
isAnimating={isExiting || isAdvancing}
isDraggable={isTopCard && !isExiting && !isAdvancing}
/>
</div>
);
})}
</div>
);
}
+42 -22
View File
@@ -12,11 +12,17 @@ import { useSwipeable } from 'react-swipeable';
interface RecommendationCardProps {
recommendation: any;
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
stackPosition?: number; // 0 = top, 1 = middle, 2 = bottom
isAnimating?: boolean; // True during exit/advance animations
isDraggable?: boolean; // False for cards behind the top card
}
export function RecommendationCard({
recommendation,
onSwipe,
stackPosition = 0,
isAnimating = false,
isDraggable = true,
}: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
@@ -36,9 +42,18 @@ export function RecommendationCard({
const swipeHandlers = useSwipeable({
onSwiping: (eventData) => {
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
// Only update drag offset if card is draggable and not animating
if (isDraggable && !isAnimating) {
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
}
},
onSwiped: (eventData) => {
// Only process swipe if card is draggable and not animating
if (!isDraggable || isAnimating) {
setDragOffset({ x: 0, y: 0 });
return;
}
// Check final position when user releases - must be at 100px threshold
const finalX = eventData.deltaX;
const finalY = eventData.deltaY;
@@ -187,27 +202,32 @@ export function RecommendationCard({
)}
</div>
{/* Desktop buttons */}
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => onSwipe('left')}
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg"
>
Not Interested
</button>
<button
onClick={() => onSwipe('up')}
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg"
>
Dismiss
</button>
<button
onClick={handleSwipeRight}
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg"
>
Request
</button>
</div>
{/* Desktop buttons - only show for top card */}
{stackPosition === 0 && (
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => !isAnimating && onSwipe('left')}
disabled={isAnimating}
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Not Interested
</button>
<button
onClick={() => !isAnimating && onSwipe('up')}
disabled={isAnimating}
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Dismiss
</button>
<button
onClick={() => !isAnimating && handleSwipeRight()}
disabled={isAnimating}
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
Request
</button>
</div>
)}
</div>
{/* Confirmation Toast */}
+62 -9
View File
@@ -6,6 +6,7 @@
'use client';
import { useState, useEffect } from 'react';
import { BookPickerModal } from './BookPickerModal';
interface SettingsWidgetProps {
isOpen: boolean;
@@ -15,7 +16,9 @@ interface SettingsWidgetProps {
}
export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboardingComplete }: SettingsWidgetProps) {
const [libraryScope, setLibraryScope] = useState<'full' | 'rated'>('full');
const [libraryScope, setLibraryScope] = useState<'full' | 'rated' | 'favorites'>('full');
const [favoriteBookIds, setFavoriteBookIds] = useState<string[]>([]);
const [showBookPicker, setShowBookPicker] = useState(false);
const [customPrompt, setCustomPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -52,6 +55,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
const data = await response.json();
setLibraryScope(data.libraryScope || 'full');
setFavoriteBookIds(data.favoriteBookIds || []);
setCustomPrompt(data.customPrompt || '');
setBackendCapabilities(data.backendCapabilities || { supportsRatings: true });
} catch (error: any) {
@@ -63,6 +67,12 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
};
const handleSave = async () => {
// Validate favorites scope
if (libraryScope === 'favorites' && favoriteBookIds.length === 0) {
setError('Please select at least 1 favorite book');
return;
}
setSaving(true);
setError(null);
setSuccessMessage(null);
@@ -78,6 +88,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
},
body: JSON.stringify({
libraryScope,
favoriteBookIds: libraryScope === 'favorites' ? favoriteBookIds : undefined,
customPrompt: trimmedPrompt || null, // Send null if empty
onboardingComplete: isOnboarding ? true : undefined,
}),
@@ -179,7 +190,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
name="libraryScope"
value="full"
checked={libraryScope === 'full'}
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated' | 'favorites')}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<div className="ml-3 flex-1">
@@ -200,7 +211,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
name="libraryScope"
value="rated"
checked={libraryScope === 'rated'}
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated' | 'favorites')}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<div className="ml-3 flex-1">
@@ -214,19 +225,52 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
</label>
)}
{/* Show info message if ratings not supported */}
{!backendCapabilities.supportsRatings && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
Note: Your backend does not support user ratings. Only "Full Library" scope is available.
{/* Pick My Favorites */}
<label className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50 ${libraryScope === 'favorites' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}>
<input
type="radio"
name="libraryScope"
value="favorites"
checked={libraryScope === 'favorites'}
onChange={(e) => {
setLibraryScope('favorites');
setShowBookPicker(true); // Auto-open book picker
}}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
/>
<div className="ml-3 flex-1">
<div className="font-medium text-gray-900 dark:text-white">
Pick my favorites
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Select up to 25 books as your personalized library
{favoriteBookIds.length > 0 && (
<span className="ml-2 text-blue-600 dark:text-blue-400 font-medium">
({favoriteBookIds.length} selected)
</span>
)}
</div>
{libraryScope === 'favorites' && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
setShowBookPicker(true);
}}
className="mt-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
>
{favoriteBookIds.length > 0 ? 'Change Selection' : 'Choose Books'}
</button>
)}
</div>
)}
</label>
</div>
</div>
{/* Custom Prompt */}
<div className="mb-6">
<label htmlFor="customPrompt" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Custom Prompt Modifier
Special Requests
<span className="text-gray-500 dark:text-gray-400 font-normal ml-2">
(Optional)
</span>
@@ -268,6 +312,15 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
)}
</div>
</div>
{/* Book Picker Modal */}
<BookPickerModal
isOpen={showBookPicker}
onClose={() => setShowBookPicker(false)}
selectedIds={favoriteBookIds}
onConfirm={(ids) => setFavoriteBookIds(ids)}
maxSelection={25}
/>
</>
);
}
+93 -15
View File
@@ -232,12 +232,12 @@ async function enrichWithUserRatings(
/**
* Get user's Plex library books based on scope
* @param userId - User ID
* @param scope - 'full' | 'listened' | 'rated'
* @param scope - 'full' | 'listened' | 'rated' | 'favorites'
* @returns Array of library books (max 40)
*/
export async function getUserLibraryBooks(
userId: string,
scope: 'full' | 'listened' | 'rated'
scope: 'full' | 'listened' | 'rated' | 'favorites'
): Promise<LibraryBook[]> {
try {
const configService = getConfigService();
@@ -249,6 +249,74 @@ export async function getUserLibraryBooks(
scope = 'full';
}
// Handle favorites scope
if (scope === 'favorites') {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { bookDateFavoriteBookIds: true },
});
const favoriteIds = user?.bookDateFavoriteBookIds
? JSON.parse(user.bookDateFavoriteBookIds)
: [];
if (favoriteIds.length === 0) {
logger.warn('Favorites scope selected but no favorites stored, falling back to full library');
scope = 'full';
} else {
// Get library ID for filtering
let libraryId: string;
if (backendMode === 'audiobookshelf') {
const absLibraryId = await configService.get('audiobookshelf.library_id');
if (!absLibraryId) {
logger.warn('No Audiobookshelf library ID configured');
return [];
}
libraryId = absLibraryId;
} else {
const plexConfig = await configService.getPlexConfig();
if (!plexConfig.libraryId) {
logger.warn('No Plex library ID configured');
return [];
}
libraryId = plexConfig.libraryId;
}
// Query favorite books
const cachedBooks = await prisma.plexLibrary.findMany({
where: {
id: { in: favoriteIds },
plexLibraryId: libraryId, // Ensure books are from current library
},
select: {
title: true,
author: true,
narrator: true,
plexGuid: true,
plexRatingKey: true,
userRating: true,
},
orderBy: { addedAt: 'desc' },
});
logger.info(`Fetched ${cachedBooks.length} favorite books for user ${userId}`);
// For Plex: Enrich with user's personal ratings
// For Audiobookshelf: Skip enrichment (no rating support)
if (backendMode === 'plex') {
return await enrichWithUserRatings(userId, cachedBooks);
} else {
// Audiobookshelf: Map to LibraryBook without ratings
return cachedBooks.map(book => ({
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
rating: undefined,
}));
}
}
}
// Get library ID based on backend mode
let libraryId: string;
if (backendMode === 'audiobookshelf') {
@@ -422,7 +490,7 @@ export async function buildAIPrompt(
): Promise<string> {
const libraryBooks = await getUserLibraryBooks(
userId,
config.libraryScope as 'full' | 'listened' | 'rated'
config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites'
);
const swipeHistory = await getUserRecentSwipes(userId, 10);
@@ -434,6 +502,27 @@ export async function buildAIPrompt(
libraryScope: config.libraryScope,
});
let instructions =
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
'CRITICAL RULES:\n' +
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
'4. Focus on variety across genres, authors, and styles\n' +
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
'6. Learn from rejected books to avoid similar recommendations\n' +
'7. Learn from requested books to find similar ones\n' +
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context';
// Add special instruction for favorites scope
if (config.libraryScope === 'favorites') {
instructions += '\n\n' +
'IMPORTANT: The user has specifically handpicked these ' + libraryBooks.length + ' books as their personal favorites. ' +
'These represent their preferred genres, authors, themes, and styles. Use these as PRIMARY INSPIRATION for your recommendations. ' +
'Find books that capture the essence of what makes these favorites special to the user.';
}
const prompt = {
task: 'recommend_audiobooks',
user_context: {
@@ -447,18 +536,7 @@ export async function buildAIPrompt(
})),
custom_preferences: config.customPrompt || null,
},
instructions:
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
'CRITICAL RULES:\n' +
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
'4. Focus on variety across genres, authors, and styles\n' +
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
'6. Learn from rejected books to avoid similar recommendations\n' +
'7. Learn from requested books to find similar ones\n' +
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context',
instructions,
};
const promptString = JSON.stringify(prompt);
+1 -1
View File
@@ -121,7 +121,7 @@ export class AudibleService {
private async fetchWithRetry(
url: string,
config: any = {},
maxRetries: number = 3
maxRetries: number = 5
): Promise<any> {
let lastError: Error | null = null;
@@ -8,6 +8,7 @@
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getLibraryService } from '../services/library';
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
export interface PlexRecentlyAddedPayload {
jobId?: string;
@@ -66,6 +67,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
// Get library service (automatically selects Plex or Audiobookshelf)
const libraryService = await getLibraryService();
const thumbnailCacheService = getThumbnailCacheService();
try {
// Get configured library ID
@@ -73,6 +75,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
? await configService.get('audiobookshelf.library_id')
: await configService.get('plex_audiobook_library_id');
// Get cover caching parameters (needed for thumbnail caching)
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
// Fetch top 10 recently added items using abstraction layer
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
@@ -93,7 +98,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
});
if (!existing) {
await prisma.plexLibrary.create({
const newLibraryItem = await prisma.plexLibrary.create({
data: {
plexGuid: item.externalId,
plexRatingKey: item.id,
@@ -111,6 +116,26 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
lastScannedAt: new Date(),
},
});
// Cache library cover (synchronous with smart skip-if-exists logic)
if (item.coverUrl && item.externalId) {
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
item.externalId,
item.coverUrl,
coverCachingParams.backendBaseUrl,
coverCachingParams.authToken,
coverCachingParams.backendMode
);
// Update database with cached path if successful
if (cachedPath) {
await prisma.plexLibrary.update({
where: { id: newLibraryItem.id },
data: { cachedLibraryCoverPath: cachedPath },
});
}
}
newCount++;
logger.info(`New item added: ${item.title} by ${item.author}`);
} else {
@@ -129,6 +154,26 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
lastScannedAt: new Date(),
},
});
// Cache library cover (synchronous with smart skip-if-exists logic)
if (item.coverUrl && item.externalId) {
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
item.externalId,
item.coverUrl,
coverCachingParams.backendBaseUrl,
coverCachingParams.authToken,
coverCachingParams.backendMode
);
// Update database with cached path if successful
if (cachedPath) {
await prisma.plexLibrary.update({
where: { id: existing.id },
data: { cachedLibraryCoverPath: cachedPath },
});
}
}
updatedCount++;
}
}
+43
View File
@@ -10,6 +10,7 @@ import { ScanPlexPayload } from '../services/job-queue.service';
import { prisma } from '../db';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
import { RMABLogger } from '../utils/logger';
/**
@@ -28,6 +29,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
const libraryService = await getLibraryService();
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
const thumbnailCacheService = getThumbnailCacheService();
logger.info(`Backend mode: ${backendMode}`);
@@ -50,6 +52,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
}
}
// Get cover caching parameters (needed for thumbnail caching)
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
logger.info(`Fetching content from library ${targetLibraryId}`);
// 3. Get all audiobooks from library using abstraction layer
@@ -97,6 +102,25 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
},
});
// Cache library cover (synchronous with smart skip-if-exists logic)
if (item.coverUrl && item.externalId) {
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
item.externalId,
item.coverUrl,
coverCachingParams.backendBaseUrl,
coverCachingParams.authToken,
coverCachingParams.backendMode
);
// Update database with cached path if successful
if (cachedPath) {
await prisma.plexLibrary.update({
where: { id: existing.id },
data: { cachedLibraryCoverPath: cachedPath },
});
}
}
updatedCount++;
} else {
// Create new plex_library entry
@@ -119,6 +143,25 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
},
});
// Cache library cover (synchronous with smart skip-if-exists logic)
if (item.coverUrl && item.externalId) {
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
item.externalId,
item.coverUrl,
coverCachingParams.backendBaseUrl,
coverCachingParams.authToken,
coverCachingParams.backendMode
);
// Update database with cached path if successful
if (cachedPath) {
await prisma.plexLibrary.update({
where: { id: newLibraryItem.id },
data: { cachedLibraryCoverPath: cachedPath },
});
}
}
newCount++;
logger.info(`Added new: "${item.title}" by ${item.author}`);
@@ -20,8 +20,10 @@ import {
triggerABSScan,
} from '../audiobookshelf/api';
import { ABSLibraryItem } from '../audiobookshelf/types';
import { getConfigService } from '@/lib/services/config.service';
export class AudiobookshelfLibraryService implements ILibraryService {
private configService = getConfigService();
async testConnection(): Promise<LibraryConnectionResult> {
try {
@@ -87,6 +89,34 @@ export class AudiobookshelfLibraryService implements ILibraryService {
await triggerABSScan(libraryId);
}
/**
* Get parameters needed for caching library covers
* @returns Parameters for ThumbnailCacheService.cacheLibraryThumbnail()
*/
async getCoverCachingParams(): Promise<{
backendBaseUrl: string;
authToken: string;
backendMode: 'plex' | 'audiobookshelf';
}> {
const config = await this.configService.getMany([
'audiobookshelf.server_url',
'audiobookshelf.api_token',
]);
const serverUrl = config['audiobookshelf.server_url'];
const authToken = config['audiobookshelf.api_token'];
if (!serverUrl || !authToken) {
throw new Error('Audiobookshelf server configuration is incomplete');
}
return {
backendBaseUrl: serverUrl,
authToken: authToken,
backendMode: 'audiobookshelf',
};
}
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
const metadata = item.media.metadata;
return {
@@ -220,6 +220,28 @@ export class PlexLibraryService implements ILibraryService {
);
}
/**
* Get parameters needed for caching library covers
* @returns Parameters for ThumbnailCacheService.cacheLibraryThumbnail()
*/
async getCoverCachingParams(): Promise<{
backendBaseUrl: string;
authToken: string;
backendMode: 'plex' | 'audiobookshelf';
}> {
const config = await this.configService.getPlexConfig();
if (!config.serverUrl || !config.authToken) {
throw new Error('Plex server configuration is incomplete');
}
return {
backendBaseUrl: config.serverUrl,
authToken: config.authToken,
backendMode: 'plex',
};
}
/**
* Map Plex audiobook to generic LibraryItem interface
*/
+156
View File
@@ -12,6 +12,7 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('ThumbnailCache');
const CACHE_DIR = '/app/cache/thumbnails';
const LIBRARY_CACHE_DIR = '/app/cache/library';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB max per image
const TIMEOUT_MS = 10000; // 10 second timeout for downloads
@@ -28,6 +29,18 @@ export class ThumbnailCacheService {
}
}
/**
* Ensure library cache directory exists
*/
private async ensureLibraryCacheDir(): Promise<void> {
try {
await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true });
} catch (error) {
logger.error('Failed to create library cache directory', { error: error instanceof Error ? error.message : String(error) });
throw error;
}
}
/**
* Generate a unique filename for a cached thumbnail
* @param asin - Audible ASIN
@@ -43,6 +56,28 @@ export class ThumbnailCacheService {
return `${asin}${ext}`;
}
/**
* Generate a unique filename for a library cover using SHA-256 hash
* @param plexGuid - Plex/ABS unique identifier (may contain special chars)
* @param url - Original URL (used for extension)
* @returns Filename for cached library cover
*/
private generateLibraryFilename(plexGuid: string, url: string): string {
// Hash the plexGuid to handle special characters (://, ?, etc.)
const hash = crypto.createHash('sha256').update(plexGuid).digest('hex').substring(0, 16);
// Extract file extension from URL (default to .jpg if not found)
let ext = '.jpg';
try {
const urlPath = new URL(url).pathname;
ext = path.extname(urlPath) || '.jpg';
} catch {
// If URL parsing fails, use default extension
}
return `${hash}${ext}`;
}
/**
* Download and cache a thumbnail from a URL
* @param asin - Audible ASIN
@@ -98,6 +133,84 @@ export class ThumbnailCacheService {
}
}
/**
* Download and cache a library thumbnail from Plex/Audiobookshelf
* @param plexGuid - Plex/ABS unique identifier
* @param coverUrl - URL of the cover (full URL or relative path)
* @param backendBaseUrl - Base URL of backend (Plex or ABS server)
* @param authToken - Authentication token
* @param backendMode - 'plex' or 'audiobookshelf'
* @returns Local file path of cached thumbnail, or null if failed
*/
async cacheLibraryThumbnail(
plexGuid: string,
coverUrl: string,
backendBaseUrl: string,
authToken: string,
backendMode: 'plex' | 'audiobookshelf'
): Promise<string | null> {
if (!coverUrl || !plexGuid || !backendBaseUrl || !authToken) {
return null;
}
try {
await this.ensureLibraryCacheDir();
const filename = this.generateLibraryFilename(plexGuid, coverUrl);
const filePath = path.join(LIBRARY_CACHE_DIR, filename);
// Check if file already exists (skip download for subsequent scans)
try {
await fs.access(filePath);
// File exists, return path immediately
return filePath;
} catch {
// File doesn't exist, proceed with download
}
// Construct full URL based on backend mode
let fullUrl: string;
if (backendMode === 'plex') {
// Plex uses token in query string
const separator = coverUrl.includes('?') ? '&' : '?';
fullUrl = `${backendBaseUrl}${coverUrl}${separator}X-Plex-Token=${authToken}`;
} else {
// Audiobookshelf uses Authorization header
fullUrl = coverUrl.startsWith('http') ? coverUrl : `${backendBaseUrl}${coverUrl}`;
}
// Download image
const response = await axios.get(fullUrl, {
responseType: 'arraybuffer',
timeout: TIMEOUT_MS,
maxContentLength: MAX_FILE_SIZE,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(backendMode === 'audiobookshelf' && { Authorization: `Bearer ${authToken}` }),
},
});
// Verify content type is an image
const contentType = response.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
logger.warn(`Invalid content type for library cover ${plexGuid}: ${contentType}`);
return null;
}
// Write to file
await fs.writeFile(filePath, Buffer.from(response.data));
logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`);
return filePath;
} catch (error) {
// Log error but don't throw - graceful degradation
logger.warn(`Failed to cache library thumbnail for ${plexGuid}`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Delete a cached thumbnail
* @param asin - Audible ASIN
@@ -150,6 +263,49 @@ export class ThumbnailCacheService {
}
}
/**
* Clean up library thumbnails that are no longer referenced in the database
* @param plexGuidToHashMap - Map of plexGuid to hash (for reverse lookup)
* @returns Number of deleted files
*/
async cleanupLibraryThumbnails(plexGuidToHashMap: Map<string, string>): Promise<number> {
try {
await this.ensureLibraryCacheDir();
const files = await fs.readdir(LIBRARY_CACHE_DIR);
let deletedCount = 0;
// Build reverse map: hash -> plexGuid
const activeHashes = new Set<string>();
for (const [plexGuid] of plexGuidToHashMap) {
// Generate hash for each plexGuid (consistent with generateLibraryFilename)
const hash = crypto.createHash('sha256').update(plexGuid).digest('hex').substring(0, 16);
activeHashes.add(hash);
}
for (const file of files) {
// Extract hash from filename (remove extension)
const hash = path.parse(file).name;
// If hash is not in active set, delete the file
if (!activeHashes.has(hash)) {
const filePath = path.join(LIBRARY_CACHE_DIR, file);
await fs.unlink(filePath);
deletedCount++;
logger.info(`Deleted unused library thumbnail: ${file}`);
}
}
logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`);
return deletedCount;
} catch (error) {
logger.error('Failed to cleanup library thumbnails', {
error: error instanceof Error ? error.message : String(error),
});
return 0;
}
}
/**
* Get the cached path for a thumbnail
* @param cachedPath - Path from database