22 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
- 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', default: 'full')
- 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, customPrompt, onboardingComplete) (All authenticated) - PUT
/api/bookdate/preferences- Update user's preferences (All authenticated)- Accepts
libraryScope('full' | 'rated'),customPrompt(max 1000 chars), andonboardingComplete(boolean)
- Accepts
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
- 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
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)