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
+7
View File
@@ -70,7 +70,11 @@
## BookDate (AI Recommendations)
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
- **Configuration, OpenAI/Claude integration** → [features/bookdate.md](features/bookdate.md)
- **Library scopes (full, rated, favorites)** → [features/bookdate.md](features/bookdate.md)
- **Pick my favorites (book selection modal)** → [features/bookdate.md](features/bookdate.md)
- **Setup wizard integration, settings** → [features/bookdate.md](features/bookdate.md)
- **Card stack animations (3-card stack, swipe animations)** → [features/bookdate-animations.md](features/bookdate-animations.md)
- **Library thumbnail caching** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
## Admin Features
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
@@ -109,7 +113,10 @@
**"What environment variables do I need?"** → [backend/services/environment.md](backend/services/environment.md)
**"How does chapter merging work?"** → [features/chapter-merging.md](features/chapter-merging.md)
**"How does logging work?"** → [backend/services/logging.md](backend/services/logging.md)
**"How do BookDate card stack animations work?"** → [features/bookdate-animations.md](features/bookdate-animations.md)
**"How does Audiobookshelf integration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How do I use OIDC/Authentik/Keycloak?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How does manual user registration work?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How do I switch from Plex to Audiobookshelf?"** → [features/audiobookshelf-integration.md](features/audiobookshelf-integration.md) (PRD only, not implemented)
**"How does library thumbnail caching work?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
+2 -1
View File
@@ -37,7 +37,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
- `title`, `author`, `narrator`, `summary`, `duration` (milliseconds), `year`, `user_rating` (0-10 scale)
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
- `file_path`, `thumb_url`, `plex_library_id`, `added_at`
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
- `last_scanned_at`, `created_at`, `updated_at`
- Indexes: `plex_guid`, `title`, `author`, `plex_library_id`, `asin`, `isbn`
- **Purpose:** Universal library cache for both Plex and Audiobookshelf backends
@@ -45,6 +45,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
- **Plex:** ASIN extracted from Plex GUID (e.g., `com.plexapp.agents.audible://B00ABC123`) + stored in dedicated field
- **Audiobookshelf:** ASIN/ISBN retrieved directly from ABS metadata + stored in dedicated fields
- **Matching:** Prioritizes exact ASIN/ISBN matches (100% confidence) before fuzzy title/author matching
- **Cached cover path:** Local path to cached library cover (e.g., `/app/cache/library/{hash}.jpg`), populated during scans
### Audiobooks
- `id` (UUID PK), `audible_asin` (nullable), `title`, `author`, `narrator`, `description`
@@ -0,0 +1,205 @@
# BookDate Card Stack Animations
**Status:** ✅ Implemented | Pure CSS card stack with smooth exit/advance animations
## Overview
Visual card stack (3 visible cards) with GPU-accelerated animations. Top card swipes away, remaining cards advance forward smoothly.
## Key Components
### CardStack.tsx
- **Location:** `src/components/bookdate/CardStack.tsx`
- **Purpose:** Orchestrates 3-card stack rendering and animation lifecycle
- **Props:**
- `recommendations: any[]` - Full recommendations array
- `currentIndex: number` - Index of current top card
- `onSwipe: (action, markedAsKnown?) => void` - Swipe handler (API call)
- `onSwipeComplete: () => void` - Called after animations finish
**Animation Flow:**
1. User swipes → `handleSwipeStart` triggered
2. Exit animation starts (400ms) → API call
3. Exit completes → `visibleCards` array updated to exclude exited card
4. Advance animation starts (350ms) → Cards move from positions 1,2,3 to 0,1,2
5. Advance completes → `onSwipeComplete` called → `currentIndex` incremented
**State Management:**
- `isExiting: boolean` - Exit animation in progress
- `exitDirection: 'left' | 'right' | 'up'` - Which exit animation to play
- `isAdvancing: boolean` - Advance animation in progress
**Visible Cards Logic:**
- **Normal:** Shows cards at `[currentIndex, currentIndex+1, currentIndex+2]` with `stackPosition` 0, 1, 2
- **During Advance:** Shows cards at `[currentIndex+1, currentIndex+2, currentIndex+3]` with `stackPosition` 0, 1, 2 and `fromPosition` 1, 2, 3
- This excludes the exited card and prevents snapping
- `fromPosition` determines which advance animation to apply
### RecommendationCard.tsx Updates
- **New Props:**
- `stackPosition?: number` - 0=top, 1=middle, 2=bottom (default: 0)
- `isAnimating?: boolean` - Disables gestures during animations (default: false)
- `isDraggable?: boolean` - Only top card accepts input (default: true)
**Behavior:**
- Swipe handlers disabled when `!isDraggable || isAnimating`
- Desktop buttons hidden when `stackPosition !== 0`
- Drag offset only updates for top card
### page.tsx Updates
- **Changed:** Import `CardStack` instead of `RecommendationCard`
- **Added:** `handleSwipeComplete()` callback
- **Modified:** `handleSwipe()` no longer increments `currentIndex` (delegated to `handleSwipeComplete`)
## CSS Animations
**Location:** `src/app/globals.css`
### Exit Animations (400ms, ease-in-out)
```css
.animate-exit-left /* translate(-150%, 50px) rotate(-25deg) */
.animate-exit-right /* translate(150%, 50px) rotate(25deg) */
.animate-exit-up /* translate(0, -120%) scale(0.8) */
```
### Advance Animations (350ms, bounce easing)
```css
.animate-advance-to-top /* scale(0.95→1.0), translateY(-12px→0) */
.animate-advance-to-middle /* scale(0.90→0.95), translateY(-24px→-12px) */
.animate-enter /* scale(0.85→0.90), translateY(-36px→-24px) */
```
### Stack Position Classes (Static)
```css
.card-stack-position-0 /* z-50, scale(1.0), translateY(0), opacity(1.0) */
.card-stack-position-1 /* z-40, scale(0.95), translateY(-12px), opacity(0.95) */
.card-stack-position-2 /* z-30, scale(0.90), translateY(-24px), opacity(0.90) */
```
### Performance Optimizations
```css
.card-stack-container /* perspective: 1000px, preserve-3d */
.card-stack-item /* will-change: transform, opacity */
```
## Animation Timing
| Phase | Duration | Easing | Description |
|-------|----------|--------|-------------|
| Exit | 400ms | ease-in-out | Top card swipes away |
| Advance | 350ms | cubic-bezier(0.34, 1.56, 0.64, 1) | Cards move forward (slight bounce) |
| Total | 750ms | - | Full swipe cycle |
**Staggering:** Advance animations start after exit completes (sequential, not overlapping).
## Edge Cases Handled
### Rapid Swipes
- **Problem:** User swipes again during animation
- **Solution:** `isAnimating` flag blocks gestures and button clicks
- **Code:** `CardStack.handleSwipeStart()` checks `isExiting || isAdvancing`
### <3 Cards Remaining
- **Problem:** Not enough cards to fill stack
- **Solution:** `CardStack` renders only available cards (0-3)
- **Behavior:** Stack naturally shrinks as user approaches end
### Undo Functionality
- **Problem:** Undo reverses card to top, but animations may be in progress
- **Solution:** `useEffect` in `CardStack` resets animation states when `currentIndex` changes externally
- **Code:** `useEffect(() => { setIsExiting(false); ... }, [currentIndex])`
### Empty State
- **Problem:** No cards to render
- **Solution:** `CardStack` returns `null`, `page.tsx` shows empty state UI
- **Trigger:** `currentIndex >= recommendations.length`
## Mobile Performance
**Target:** 60fps on mobile devices
**Optimizations:**
- GPU-accelerated properties only (`transform`, `opacity`, not `left/top/width`)
- `will-change: transform, opacity` hints browser to optimize
- `backface-visibility: hidden` prevents rendering artifacts
- No layout shift (cards positioned absolutely)
**Tested On:**
- Chrome (desktop + mobile)
- Safari (iOS + macOS)
- Firefox
## User Experience
**Visual Hierarchy:**
- Top card: Full size, interactive, clear visuals
- Card 2: 95% scale, 95% opacity, visible but de-emphasized
- Card 3: 90% scale, 90% opacity, subtle depth cue
**Swipe Directions:**
- Left: Reject (red overlay, rotate left)
- Right: Request (green overlay, confirm toast, rotate right)
- Up: Dismiss (blue overlay, shrink up)
**Toast Confirmation:**
- Right swipe triggers toast modal
- User chooses: "Request" or "Mark as Liked"
- Card exit animation plays after choice
## Integration with Existing Features
### Settings Widget
- **Status:** No changes required
- **Behavior:** Opens over card stack, gestures disabled when modal open
### Undo Button
- **Status:** Works with stack
- **Behavior:** Triggers `loadRecommendations()` → Cards re-render from API
- **Animation:** No special animation (instant reset to fresh state)
### Progress Indicator
- **Status:** No changes required
- **Display:** Shows `currentIndex + 1 / recommendations.length`
### Desktop Buttons
- **Status:** Updated to disable during animations
- **Code:** `disabled={isAnimating}` in `RecommendationCard.tsx:217-234`
## Troubleshooting
### Cards Not Stacking
- **Check:** CSS classes applied correctly in `CardStack.tsx`
- **Verify:** `card-stack-position-{0,1,2}` classes present in `globals.css`
- **Debug:** Inspect z-index values (50, 40, 30)
### Cards Snapping Instead of Animating
- **Root Cause:** Exited card still in `visibleCards` array during advance phase
- **Fix:** During `isAdvancing`, `visibleCards` starts from `currentIndex + 1` (skips exited card)
- **Verify:** Check `CardStack.tsx:71-97` - advance branch excludes card at `currentIndex`
### Animations Not Playing
- **Check:** Exit/advance animation classes applied during state transitions
- **Verify:** `animationClass` computed correctly based on `card.fromPosition` during advance
- **Debug:** Console log `isExiting`, `exitDirection`, `isAdvancing`, `visibleCards`
### Gestures Not Working
- **Check:** `isDraggable` prop passed correctly (only true for top card)
- **Verify:** `isAnimating` not stuck in true state
- **Debug:** Check `CardStack` animation state machine
### Performance Issues
- **Check:** Animations targeting only `transform` and `opacity`
- **Verify:** `will-change` applied to `.card-stack-item`
- **Test:** Chrome DevTools Performance tab (60fps target)
## Related Files
- **Documentation:** `documentation/features/bookdate-prd.md` (BookDate feature spec)
- **Components:** `src/components/bookdate/LoadingScreen.tsx`, `SettingsWidget.tsx`
- **API:** `src/app/api/bookdate/swipe/route.ts`
## Future Enhancements (Not Implemented)
- **Preload Card 4:** Load image for 4th card in stack (currently loads on-demand)
- **Spring Physics:** Replace CSS easing with spring animations for more natural feel
- **Haptic Feedback:** Vibrate on swipe (requires Web Vibration API)
- **Parallax Effect:** Cards shift slightly on device tilt (requires DeviceOrientation API)
+22 -3
View File
@@ -14,6 +14,10 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p
- 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
@@ -39,7 +43,8 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p
### User (per-user preferences)
```prisma
- bookDateLibraryScope ('full' | 'rated', default: 'full')
- 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)
```
@@ -74,9 +79,13 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p
- 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)
- 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'), `customPrompt` (max 1000 chars), and `onboardingComplete` (boolean)
- Accepts `libraryScope` ('full' | 'rated' | 'favorites'), `favoriteBookIds` (array, max 25), `customPrompt` (max 1000 chars), and `onboardingComplete` (boolean)
- Validates favorites scope requires at least 1 book selected
- 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)
@@ -98,6 +107,16 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p
- 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
- 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
@@ -0,0 +1,121 @@
# Library Thumbnail Caching
**Status:** ✅ Implemented | Cache library covers during scans, serve in BookDate
## Overview
Caches book covers from Plex/Audiobookshelf during library scans. Stores cached files in `/app/cache/library/` with SHA-256 hashed filenames. Dramatically improves BookDate user experience by showing real covers instead of placeholders.
## Key Details
### Caching Strategy
- **When:** During full scans (scan-plex.processor.ts) and recently-added scans (plex-recently-added.processor.ts)
- **Where:** `/app/cache/library/` directory
- **Filename:** SHA-256 hash (first 16 chars) of plexGuid + extension (e.g., `a3f5e9d2c1b4.jpg`)
- **Smart caching:** Checks if file exists before downloading (subsequent scans are fast)
### Database Schema
- **Field:** `PlexLibrary.cachedLibraryCoverPath` (nullable TEXT)
- **Stores:** Full path like `/app/cache/library/{hash}.jpg`
- **Migration:** `20260120000000_add_cached_library_cover_path`
### URL Construction (Backend-Specific)
- **Plex:** `{serverUrl}{thumbUrl}?X-Plex-Token={token}`
- **Audiobookshelf:** `{serverUrl}{coverPath}` with `Authorization: Bearer {token}` header
### Cover Priority (BookDate Library Picker)
1. **Library cached cover** (`cachedLibraryCoverPath`) → `/api/cache/library/{filename}`
2. **Audible cache** (if book has ASIN) → from `AudibleCache.coverArtUrl`
3. **Null** (show placeholder 📚)
## API Endpoints
### GET /api/cache/library/[filename]
Serves cached library covers (24-hour browser cache).
**Path validation:** Prevents directory traversal (rejects `..` and `/`).
**Content types:** jpg, jpeg, png, gif, webp → image/*, else application/octet-stream
### GET /api/bookdate/library
Returns library books with cover URLs.
**Response:**
```json
{
"books": [
{
"id": "uuid",
"title": "Book Title",
"author": "Author Name",
"coverUrl": "/api/cache/library/a3f5e9d2c1b4.jpg" // or Audible URL, or null
}
]
}
```
## Service Layer
### ThumbnailCacheService
Located: `src/lib/services/thumbnail-cache.service.ts`
**Methods:**
- `cacheLibraryThumbnail(plexGuid, coverUrl, backendBaseUrl, authToken, backendMode)` → Returns cached path or null
- `cleanupLibraryThumbnails(plexGuidToHashMap)` → Returns deleted count
**Safeguards:**
- 10s timeout per download
- 5MB max file size
- Content-type validation (must be image/*)
- Graceful degradation (logs warning, returns null on failure)
### Library Services
Located: `src/lib/services/library/`
**Both PlexLibraryService and AudiobookshelfLibraryService provide:**
- `getCoverCachingParams()` → Returns `{ backendBaseUrl, authToken, backendMode }`
## Performance
### First Full Scan (1000 books)
- Database: ~30 seconds
- Downloads: ~1-5 minutes (network-dependent)
- **Total: ~1.5-5.5 minutes** (one-time cost)
### Subsequent Scans (1000 books)
- Database: ~30 seconds
- Downloads: **~0 seconds** (skipped, files exist)
- **Total: ~30 seconds** (same as before caching)
### BookDate Library Load
- **Before:** Mostly placeholder covers
- **After:** Real covers for all books with valid thumbUrl
- **Performance:** No change (local file serving is fast)
## Error Handling
- Download fails → log warning, store null, continue scan
- Invalid content-type → reject, store null
- File system errors → log, store null
- Missing backend config → throw (scan fails early with clear error)
## Cleanup (Future Enhancement)
**Manual or Scheduled:**
- Builds hash-to-plexGuid reverse map from database
- Deletes cached files for plexGuids no longer in library
- Returns count of deleted files
**Trigger:** Admin endpoint or weekly scheduled job
## Docker Configuration
**Volume mount required:**
```yaml
volumes:
- ./cache/library:/app/cache/library
```
Ensures cached covers persist across container restarts.
## Related
- documentation/backend/database.md (PlexLibrary schema)
- documentation/features/bookdate.md (cover loading logic)
- documentation/integrations/audible.md (Audible thumbnail caching pattern)
- documentation/backend/services/jobs.md (scan processors)