Files
ReadMeABook/documentation/features/bookdate.md
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
2026-01-28 11:41:59 -05:00

24 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: true to 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 isEnabled field for global admin toggle
  • 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), and onboardingComplete (boolean)

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 /bookdate page
    • 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:

  1. User visits /bookdate for first time (bookDateOnboardingComplete=false)
  2. Settings modal opens automatically with welcome message
  3. User configures library scope and custom prompt preferences
  4. User clicks "Let's Go!" button
  5. Preferences saved with onboardingComplete=true
  6. Modal closes, recommendations begin generating
  7. Subsequent visits skip onboarding, load recommendations directly

AI Prompt Flow

  1. 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
  2. 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)
  3. 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
  4. Response:

    • Return recommendations with metadata (title, author, cover, rating, AI reason)

Request Integration

Right Swipe Flow:

  1. User swipes right (150px minimum) → Shows confirmation toast
  2. User selects "Request" → Creates Audiobook + Request records + triggers search job
  3. User selects "Mark as Known" → Records swipe only (no request)
  4. Request appears in /requests page, 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/resources with 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}/all with 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)

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 ORM
  • encryption.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 button
  • src/components/bookdate/RecommendationCard.tsx - Swipeable card
  • src/components/bookdate/SettingsWidget.tsx - Per-user preferences modal (supports onboarding mode)
  • src/components/bookdate/LoadingScreen.tsx - Loading animation
  • src/app/admin/settings/page.tsx - Admin settings (BookDate tab)
  • src/app/setup/steps/BookDateStep.tsx - Setup wizard step
  • src/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.libraryScope field, 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
  • Fix: Removed libraryScope from required condition in setup completion route
    • Now checks: if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model)
    • Sets libraryScope: 'full' and customPrompt: null as defaults (backwards compatibility)
    • Config saves with isVerified: true, isEnabled: true → BookDate tab appears immediately
  • 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 set isOnboarding=true but recommendations.length === 0
    • Empty state check ran before onboarding check, rendered "Get More Recommendations"
  • 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-connection required 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: true in 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: isInLibrary used weak string contains matching 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 calls findPlexMatch() 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/generate endpoint 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/recommendations which correctly filtered: where: { userId, swipes: { none: {} } }
  • 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
  • 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
  • Files updated: src/app/api/bookdate/test-connection/route.ts:190-197,382-389, tests/api/bookdate-test-connection.routes.test.ts:254-294