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.
25 KiB
BookDate Feature
Status: ✅ Implemented | AI-powered audiobook recommendations with Tinder-style swipe interface
Overview
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
Key Details
- AI Providers: OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku)
- Configuration: Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
- Personalization: Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
- Library Scopes (per-user):
- Full library: All books in library (max 40 most recent)
- Rated only: Only books the user has rated
- Local admin: Uses cached ratings from system token
- Plex users: Fetches 100 books, filters to user's rated books, returns top 40
- Pick my favorites: User selects up to 25 specific books as their personalized library
- Book picker modal with search, grid view, visual selection feedback
- AI receives special instruction that these are user's handpicked favorites
- Falls back to full library if no favorites selected
- Custom Prompt (per-user): Optional preferences (max 1000 chars) to guide recommendations
- Context Window: Max 50 books (40 library + 10 swipe history) per user
- Cache: All unswiped recommendations persisted per user, shown on return
- Actions:
- Left swipe: Reject (can undo) - requires 150px swipe distance
- Right swipe: Request (shows confirmation toast: "Request" or "Mark as Known", triggers search job)
- Up swipe: Dismiss (can undo) - requires 150px swipe distance
- Enable/Disable: Admin global toggle to enable/disable feature for all users
- Visibility: Tab shown to any authenticated user when admin has configured and enabled BookDate
Database Models
BookDateConfig (global singleton - one record)
- id (single record)
- provider ('openai' | 'claude')
- apiKey (encrypted, shared by all users)
- model (e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929')
- libraryScope (DEPRECATED: now per-user in User model)
- customPrompt (DEPRECATED: now per-user in User model)
- isVerified (admin tested connection), isEnabled (admin toggle)
User (per-user preferences)
- bookDateLibraryScope ('full' | 'rated' | 'favorites', default: 'full')
- bookDateFavoriteBookIds (JSON string array of PlexLibrary IDs, max 25, nullable)
- bookDateCustomPrompt (optional, max 1000 chars)
- bookDateOnboardingComplete (boolean, default: false)
BookDateRecommendation (cached)
- userId, batchId (groups same AI call)
- title, author, narrator, rating, description, coverUrl
- audnexusAsin (for matching/requesting)
- aiReason (why AI recommended)
BookDateSwipe (history)
- userId, recommendationId
- bookTitle, bookAuthor
- action ('left' | 'right' | 'up')
- markedAsKnown (true if "Mark as Known" in toast)
API Endpoints
Global Configuration (Admin):
- POST
/api/bookdate/test-connection- Validate API key (saved or new), fetch models- Auth: Optional (unauthenticated during setup wizard, authenticated for saved keys in settings)
- Supports
useSavedKey: trueto test with encrypted saved API key (requires authentication)
- GET
/api/bookdate/config- Get global config (excluding API key) (All authenticated) - POST
/api/bookdate/config- Create/update global config (Admin only)- Accepts optional
apiKey(only required for initial setup) - Includes
isEnabledfield for global admin toggle
- Accepts optional
- DELETE
/api/bookdate/config- Delete global config (Admin only) - DELETE
/api/bookdate/swipes- Clear ALL users' swipe history (Admin only)
User Preferences:
- GET
/api/bookdate/preferences- Get user's BookDate preferences (libraryScope, favoriteBookIds, customPrompt, onboardingComplete) (All authenticated) - PUT
/api/bookdate/preferences- Update user's preferences (All authenticated)- Accepts
libraryScope('full' | 'rated' | 'favorites'),favoriteBookIds(array, max 25),customPrompt(max 1000 chars), andonboardingComplete(boolean) - Validates favorites scope requires at least 1 book selected
- Accepts
- GET
/api/bookdate/library- Get user's full library for book picker modal (All authenticated)- Returns books with id, title, author, coverUrl
- Cover priority: Library cached cover → Audible cache → null (see library-thumbnail-cache.md)
Recommendations:
- GET
/api/bookdate/recommendations- Return user's cached unswiped recommendations (All authenticated) - POST
/api/bookdate/swipe- Record user's swipe, create request + trigger search job if right+confirm (All authenticated) - POST
/api/bookdate/undo- Undo last swipe (left/up only) (All authenticated) - POST
/api/bookdate/generate- Force generate new batch (All authenticated)
UI Components
Pages:
/bookdate- Main swipe interface (mobile gestures + desktop buttons) + user preferences settings (All authenticated users)- Onboarding Flow: First-time users see settings modal before recommendations
/admin/settings- BookDate global configuration tab (Admin only)- Header navigation - BookDate tab visible to all authenticated users when admin has configured and enabled
Components:
RecommendationCard- Swipeable card with 150px delta threshold, responsive height (max 80vh mobile, 85vh desktop)- Cover image scales dynamically (max 25vh mobile with 300px cap) to ensure all content fits
- Mobile-optimized: Reduced padding, smaller text, line-clamped AI reason
SettingsWidget- Per-user preferences modal (library scope, custom prompt) in/bookdatepage- Supports onboarding mode with "Welcome" header and "Let's Go!" button
- Includes "Pick my favorites" radio option that opens BookPickerModal
- Shows selection count when favorites scope selected
BookPickerModal- Book selection modal for favorites scope (max 25 books)- Grid view with cover images (5 cols desktop, 2 cols mobile)
- Search/filter by title or author
- Visual selection feedback (blue ring, checkmark overlay)
- Real-time selection counter (X/25)
- Disabled state when max reached
- Staggered fade-in animations
- Preserves selection on cancel
- Cannot be closed during onboarding (no X button)
LoadingScreen- Animated loading state- Navigation tab - Shows to any user with verified configuration
First-Time User Experience
Onboarding Flow:
- User visits
/bookdatefor first time (bookDateOnboardingComplete=false) - Settings modal opens automatically with welcome message
- User configures library scope and custom prompt preferences
- User clicks "Let's Go!" button
- Preferences saved with onboardingComplete=true
- Modal closes, recommendations begin generating
- Subsequent visits skip onboarding, load recommendations directly
AI Prompt Flow
-
Context Gathering:
- Get user's library books (max 40, filtered by scope)
- Local Admin Users: Use cached ratings (from system Plex token configured during setup)
- Plex-Authenticated Users (including admins): Fetch library with user's token to get personal ratings
- Get recent swipes (max 10, prioritized: non-dismiss actions first, then dismissals)
- Prioritizes most informative swipes: up to 10 likes/requests/dislikes (left/right swipes)
- Fills remaining slots with most recent dismissals (up swipes)
- Rationale: Non-dismiss actions provide stronger preference signals for AI recommendations
- Add custom prompt if provided
- Get user's library books (max 40, filtered by scope)
-
AI Call:
- OpenAI:
response_format: {type: "json_object"}, system prompt enforces JSON - Claude: System prompt: "Return ONLY valid JSON"
- Request: 20 recommendations (expect ~10 after filtering)
- OpenAI:
-
Post-Processing:
- Match to Audnexus (database cache first, API fallback)
- Filter: Already in library (uses centralized audiobook-matcher.ts - same as homepage), already requested, already swiped
- Two-stage library filtering:
- Stage 1: Fuzzy match with AI-provided title/author (before Audnexus)
- Stage 2: ASIN + fuzzy match with Audnexus title/author (after Audnexus lookup)
- Matching algorithm: Title normalization, ASIN matching, weighted scoring (title 70% + author 30%), 70% threshold
- Store top 10 in cache
-
Response:
- Return recommendations with metadata (title, author, cover, rating, AI reason)
Request Integration
Right Swipe Flow:
- User swipes right (150px minimum) → Shows confirmation toast
- User selects "Request" → Creates
Audiobook+Requestrecords + triggers search job - User selects "Mark as Known" → Records swipe only (no request)
- Request appears in
/requestspage, search job begins automatically (same as regular requests)
Setup Wizard Integration
Step 7 (between Paths and Review):
- Provider selection dropdown
- API key input (password-masked)
- "Test Connection & Fetch Models" button
- Model dropdown (populated after successful test)
- Note: Library scope and custom prompt configured per-user after setup
- "Skip for now" + "Next" buttons
- Config saved in
/api/setup/complete(optional, only if filled)
Settings Pages
Admin Settings (/admin/settings - BookDate Tab):
- Enable/Disable Toggle: Global feature toggle (preserves all settings)
- Provider Selection: OpenAI or Claude
- API Key: Optional re-entry (leave blank to keep existing, required for initial setup)
- Shows placeholder "••••••••" if already configured
- Test Connection: Uses saved API key if no new key entered
- Button text changes to indicate using saved key
- Model Selection: Populated after successful test
- Save: Can save provider/model/enabled without re-testing
- Testing only required when changing provider/API key/model
- Clear Swipe History: Button with confirmation dialog (clears ALL users' history)
- Note: Library scope and custom prompt are now per-user settings
- Accessible to admins only
User Preferences (/bookdate page - Settings Icon):
- Library Scope: Full library | Rated only (default: full)
- Custom Prompt: Optional preferences (max 1000 chars, default: blank)
- Save: Updates user's preferences immediately
- Accessible to all authenticated users
Security
- API Keys: Encrypted at rest (AES-256-GCM), never logged
- User Isolation: All queries filtered by userId
- Admin Controls: Can disable globally, cannot see user API keys
- No Shared Keys: Each user provides their own (no centralized costs)
Error Handling
Configuration Errors:
- Invalid API key → "Invalid API key. Please check and try again."
- Connection failed → "Could not connect to {provider}. Check your API key and internet connection."
- Model fetch failed → Show error, allow manual model entry
Recommendation Errors:
- AI API call failed → Check cache first, show cached if available, else error
- Invalid JSON response → Log full response, retry once, then show error
- All recommendations filtered out → Show message: "Couldn't find new recommendations. Try adjusting settings."
- No Audnexus match → Skip silently, log warning, continue with next
Per-User Rating Handling:
- Local admin users: Use cached ratings from library scan
- Cached ratings are from the system Plex token (configured during setup)
- No additional API calls needed
- scope='rated': Filters cached library by cached ratings (40 most recent rated books)
- Plex-authenticated users (including admins): Fetch library with server-specific access token
- User's plex.tv OAuth token (from authToken) →
/api/v2/resourceswith stored machineIdentifier → server access token - Per Plex API docs: plex.tv tokens are for plex.tv, server tokens are for PMS
- Uses server access token to call
/library/sections/{id}/allwith user's personal ratings - Matches by plexGuid/ratingKey against cached library structure
- ~1-2s fetch time for full library (only happens when generating recommendations)
- scope='rated': Fetches 100 books, enriches with user ratings, filters to rated, returns top 40
- Ensures user sees books THEY rated
- Security: Users never access or decrypt the system Plex token (machineIdentifier stored in config)
- User's plex.tv OAuth token (from authToken) →
Graceful Degradation:
- Audnexus API down → Skip failed matches, show what matched
- Empty Plex library → Show warning, allow setup anyway
- No recommendations → Show empty state with "Get More" button
- Rating fetch fails → Continue with recommendations, no ratings included in AI prompt
Cache Strategy
- Per-User: Each user has separate cache
- Return Behavior: Shows all remaining unswiped cached recommendations when user returns
- Invalidation: Cleared when config changes or user clears manually
- Persistence: Remains until swiped (no expiration)
- Refill: User manually requests more when cache is empty
Mobile UX
- Touch Gestures: Swipe left/right/up with visual feedback (150px minimum distance)
- Drag Overlay: Green (right), Red (left), Blue (up) with emoji indicators
- Overlay visible at 50px offset, full opacity at 150px
- Rotation: Card rotates slightly during drag
- Snap Back: Card returns if released before 150px threshold
- Responsive Layout: Optimized for mobile viewing
- Card max 80vh (mobile) vs 85vh (desktop)
- Cover image max 25vh (mobile, 300px cap) to fit all content on screen
- Reduced padding (1rem mobile vs 1.5rem desktop)
- Smaller text sizing on mobile
- AI reason line-clamped to 3 lines to prevent overflow
- Compact progress indicator and swipe hint spacing
- Undo: Appears for 3 seconds after left/up swipe
Desktop UX
- Button Controls: 3 buttons below card (Not Interested, Dismiss, Request)
- Mouse Drag: Also supports mouse dragging for swipe
- Keyboard: No shortcuts (future enhancement)
Performance
- Token Usage: ~4,500 input + ~1,000 output tokens per batch
- Cost Estimate: ~$0.04 per batch (GPT-4o), varies by model
- Cache Hit Rate: High (only generates when needed)
- API Rate Limits: OpenAI ~3500 RPM, Claude ~4000 RPM
- Per-User Rating Fetch:
- Local admin users: No additional API calls (use cached ratings)
- Plex-authenticated users: 1 library fetch (~1-2s for full library)
- Only happens when generating recommendations (not frequently)
Dependencies
react-swipeable(^7.0.1) - Swipe gesture handling@prisma/client- Database ORMencryption.service.ts- API key encryption
File Locations
Backend:
prisma/schema.prisma- Database models (User.bookDateOnboardingComplete, BookDateConfig, BookDateRecommendation, BookDateSwipe)src/lib/bookdate/helpers.ts- Helper functions (AI calling, matching, filtering)src/app/api/bookdate/- API routes (config, preferences, recommendations, swipe, undo, generate)src/app/api/bookdate/preferences/route.ts- User preferences API (GET, PUT with onboarding tracking)
Frontend:
src/app/bookdate/page.tsx- Main swipe interface + onboarding flow + settings buttonsrc/components/bookdate/RecommendationCard.tsx- Swipeable cardsrc/components/bookdate/SettingsWidget.tsx- Per-user preferences modal (supports onboarding mode)src/components/bookdate/LoadingScreen.tsx- Loading animationsrc/app/admin/settings/page.tsx- Admin settings (BookDate tab)src/app/setup/steps/BookDateStep.tsx- Setup wizard stepsrc/components/layout/Header.tsx- Navigation (conditional BookDate tab)
Fixed Issues ✅
1. Setup Wizard Not Saving BookDate Configuration
- Issue: After configuring BookDate in setup wizard, tab doesn't appear; must re-configure in settings
- User Experience: "I set it up in wizard, but have to go back to settings and re-enter everything"
- Cause: Setup completion route required
bookdate.libraryScopefield, but wizard step doesn't collect it (now per-user)- Condition:
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model && bookdate.libraryScope) - Wizard only collects: provider, apiKey, model (libraryScope/customPrompt are per-user preferences)
- Config never saved, BookDate tab never appeared
- Condition:
- Fix: Removed
libraryScopefrom required condition in setup completion route- Now checks:
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) - Sets
libraryScope: 'full'andcustomPrompt: nullas defaults (backwards compatibility) - Config saves with
isVerified: true, isEnabled: true→ BookDate tab appears immediately
- Now checks:
- Files updated:
src/app/api/setup/complete/route.ts:163-205
2. Onboarding Modal Showing After Empty State
- Issue: First-time users saw empty state with "Generate More Recommendations" button instead of onboarding settings
- User Experience: "Didn't see onboarding, just empty state buttons. After generating, onboarding finally showed"
- Cause: Render logic checked empty recommendations before checking onboarding status
- When
onboardingComplete=false, page setisOnboarding=truebutrecommendations.length === 0 - Empty state check ran before onboarding check, rendered "Get More Recommendations"
- When
- Fix: Added dedicated onboarding state check before empty state check
- New render order: Loading → Onboarding → Error → Empty → Normal
- Onboarding state shows welcome message + settings modal immediately
- After completion, modal closes and recommendations generate
- Files updated:
src/app/bookdate/page.tsx:233-258
3. Undo Restores Card at Front with Full Information
- Issue: When undoing a dismiss/dislike, card appeared at back of stack with "Previously dismissed" and lost data
- User Experience: "When I undo, it gets added to the back of the stack and loses all info"
- Cause: Original implementation deleted recommendation on swipe, then recreated it with new timestamp
- Swipe endpoint deleted BookDateRecommendation after creating swipe record
- Undo endpoint tried to recreate from swipe data (only had title/author)
- New createdAt timestamp put card at end when ordered by 'asc'
- Fix: Keep recommendations in database, filter by swipe status
- Swipe endpoint no longer deletes recommendations (just creates swipe record)
- Recommendations endpoint filters out any with associated swipes (
swipes: { none: {} }) - Undo endpoint deletes swipe + updates createdAt to front of stack
- All original data preserved (narrator, rating, description, coverUrl, aiReason, etc.)
- Files updated:
src/app/api/bookdate/swipe/route.ts,src/app/api/bookdate/undo/route.ts,src/app/api/bookdate/recommendations/route.ts,src/app/bookdate/page.tsx
4. Setup Wizard Auth Error on Test Connection
- Issue: "Test Connection" in setup wizard fails with auth error, but works in settings with same API key
- User Experience: Unable to configure BookDate during initial setup wizard
- Cause:
/api/bookdate/test-connectionrequired authentication, but setup wizard runs before user login- Wizard tried to send Authorization header from localStorage (doesn't exist during setup)
- Settings page works because user is already authenticated
- Fix: Modified endpoint to support optional authentication
- Unauthenticated: Allowed during setup wizard (tests provided API key only)
- Authenticated: Required when using
useSavedKey: truein settings (accesses saved encrypted key) - Route checks for Authorization header presence to determine flow
- Files updated:
src/app/api/bookdate/test-connection/route.ts,src/app/setup/steps/BookDateStep.tsx
5. Library Books Appearing in Recommendations
- Issue: Books already in Plex library were being recommended despite filtering
- User Experience: "Getting books recommended by BookDate that are already in my library"
- Cause:
isInLibraryused weak stringcontainsmatching instead of robust fuzzy matching- Didn't match title variations (e.g., "The Tenant" vs "The Tenant (Unabridged)")
- Didn't support ASIN matching for exact identification
- Didn't normalize titles (remove "(Unabridged)", "(Abridged)", etc.)
- Used AND logic (both title and author must contain) instead of weighted scoring
- Fix: Updated BookDate filtering to use centralized
audiobook-matcher.ts(same as homepage)isInLibrary()now callsfindPlexMatch()for consistent matching behavior- Two-stage filtering: fuzzy match before Audnexus, then ASIN + fuzzy match after
- Title normalization: Removes "(Unabridged)", "(Abridged)", series numbers, etc.
- ASIN exact matching: Checks plexGuid for exact ASIN (100% confidence)
- Weighted scoring: title * 0.7 + author * 0.3 >= 0.7 threshold
- Narrator support: Can match narrator to Plex author field
- Files updated:
src/lib/bookdate/helpers.ts,src/app/api/bookdate/generate/route.ts,src/app/api/bookdate/recommendations/route.ts
6. Mobile Layout Cramped - AI Reason Overflow and Content Not Fitting
- Issue: On mobile, AI reason text fell off card, full page content didn't fit (had to scroll between rating and swipe instructions)
- User Experience: "The AI 'reason' falls off the card and can't be read. The x/10 at top and swiping instructions at bottom don't fit, I have to scroll carefully to see them one at a time"
- Cause: Card and cover image were sized for desktop (85vh card, 40vh cover), leaving insufficient space for all mobile content
- Cover image too large (40vh) consumed most of card height
- Fixed text sizes and padding didn't scale down for mobile
- AI reason box could overflow without line limiting
- Page elements (progress, card, swipe hint) exceeded viewport height
- Fix: Implemented responsive mobile-first layout with dynamic scaling
- Card height: 80vh (mobile) vs 85vh (desktop) for more breathing room
- Cover image: 25vh max (mobile, 300px cap) vs 40vh (desktop) - 37.5% reduction
- Responsive padding: 1rem (mobile) vs 1.5rem (desktop) throughout card
- Responsive text sizing: smaller fonts on mobile (text-xs/sm/base vs text-sm/lg/xl)
- AI reason: Added line-clamp-3 to prevent overflow, always visible
- Page spacing: Reduced margins on progress indicator, swipe hint, undo button for mobile
- Result: All content (rating, description, AI reason) fits within single viewport without scrolling
- Files updated:
src/components/bookdate/RecommendationCard.tsx,src/app/bookdate/page.tsx
7. Generate Endpoint Returning Swiped Recommendations
- Issue: Users saw same 10 recommendations repeatedly after clicking "Get More Recommendations"
- User Experience: "Seeing the same 10 recommendations over and over, but logs show different ones being generated"
- Cause:
/api/bookdate/generateendpoint generated new recommendations correctly but final query didn't filter out swiped items- Line 147-151:
findMany({ where: { userId } })returned ALL recommendations including swiped ones - Since ordered by
createdAt: 'asc', old swiped recommendations appeared first - New recommendations were generated but hidden behind old swiped ones
- Contrast with
/api/bookdate/recommendationswhich correctly filtered:where: { userId, swipes: { none: {} } }
- Line 147-151:
- Fix: Added swipe filter to final query in generate endpoint
- Updated query:
where: { userId, swipes: { none: {} } } - Now returns only unswiped recommendations (including newly generated ones)
- Consistent with recommendations endpoint filtering behavior
- Updated query:
- Files updated:
src/app/api/bookdate/generate/route.ts:147-157
8. Test Connection with Bad Custom LLM Credentials Logs User Out
- Issue: Testing custom LLM connection with invalid credentials causes user to be logged out instead of showing error
- User Experience: "When I test connection with wrong API key, I get logged out of ReadMeABook instead of seeing an error message"
- Cause: Custom LLM provider returns 401 Unauthorized for invalid credentials
- Test-connection endpoint passed through external service's 401 status code
fetchWithAuth()utility intercepts ALL 401 responses, assuming they indicate expired user session- Triggers automatic token refresh, then logout if refresh fails or still 401
- User logged out of application when only external service credentials were invalid
- Fix: Return 400 Bad Request instead of passing through external service status codes
- Changed lines 196, 387 in
src/app/api/bookdate/test-connection/route.ts - Now returns
{ status: 400 }for all custom provider connection failures - Reserve 401 status exclusively for application authentication issues
- External service credential failures are client errors (400), not auth errors (401)
- Added tests to verify 401 from external provider returns 400 to client
- Changed lines 196, 387 in
- Files updated:
src/app/api/bookdate/test-connection/route.ts:190-197,382-389,tests/api/bookdate-test-connection.routes.test.ts:254-294
Related
- Full requirements: features/bookdate-prd.md
- Authentication: backend/services/auth.md
- Database: backend/database.md
- Setup wizard: setup-wizard.md
- Matching algorithm: ../integrations/plex.md (Fixed Issues #7)