Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+696
View File
@@ -0,0 +1,696 @@
# BookDate Feature - Product Requirements Document
**Status:** ⏳ Planning Phase
**Version:** 1.0
**Last Updated:** 2025-11-19
**Owner:** Product Team
---
## 1. Executive Summary
**What:** An AI-powered audiobook recommendation system that presents personalized suggestions in a Tinder-style swipe interface, learning from user preferences to provide increasingly accurate recommendations.
**Why:** Current audiobook discovery relies on manual browsing. BookDate leverages AI and user listening history to surface relevant audiobooks users might not discover otherwise, increasing engagement and library utilization.
**Target Users:** All ReadMeABook users (global admin-managed AI, per-user personalization based on individual library and swipe history)
---
## 2. Feature Overview
### Core Experience
1. **Setup:** Admin configures AI provider, API key, model, library scope, and custom prompt (global for all users)
2. **Discovery:** AI generates personalized audiobook recommendations based on each user's individual Plex library, ratings, and swipe history
3. **Interaction:** Swipe right (request), left (not interested), or up (neutral) on recommendations
4. **Learning:** AI refines future recommendations based on individual swipe history
### Key Differentiation
- **Personalized:** Recommendations based on each individual user's library, listening history, ratings, and swipe patterns
- **AI-Powered:** Leverages LLMs (OpenAI, Claude) for sophisticated matching
- **Engaging:** Tinder-style interface makes discovery fun and frictionless
- **Centrally Managed:** Admin configures AI once, all users benefit with personalized recommendations
---
## 3. User Personas
### Primary Persona: Active Listener
- **Profile:** Regularly listens to audiobooks, has completed 20+ books
- **Goal:** Discover new audiobooks similar to favorites without manual searching
- **Pain Point:** Overwhelmed by options, misses hidden gems
### Secondary Persona: Curator
- **Profile:** Admin managing family/friend Plex library
- **Goal:** Curate recommendations for diverse user preferences
- **Pain Point:** Different users have different tastes
---
## 4. Configuration Flow
### 4.1 Admin Settings Page
**Location:** /admin/settings → BookDate tab (Admin only)
**Access Control:** Only users with admin role can access and configure BookDate
UI Elements:
1. **Enable/Disable Toggle:** (visible when configured)
- Toggle switch: Enable/Disable BookDate Feature
- Label: "BookDate Feature"
- Help text: "When enabled, all users can access BookDate recommendations. When disabled, the BookDate tab is hidden for all users."
2. **AI Provider Selection:**
- Dropdown: OpenAI, Claude (Anthropic)
- Label: "Choose AI Provider"
3. **API Key Input:**
- Text field (password-style masking)
- Label: "API Key"
- Help text: "The API key is stored securely and encrypted. All users share this API key but receive personalized recommendations."
- Placeholder: "sk-..." (OpenAI) or "sk-ant-..." (Claude)
- Can leave blank to keep existing key when updating other settings
4. **Test Connection Button:**
- Label: "Test Connection & Fetch Models"
- Can test with saved API key or newly entered key
- Action: Validates API key, fetches available models
- Success: Enables model selection dropdown
- Failure: Shows error message
5. **Model Selection:**
- Dropdown: Populated by API response (e.g., "gpt-4o", "claude-sonnet-4-5")
- Label: "Select Model"
- Disabled until connection tested
6. **Library Scope:**
- Radio buttons:
- "Full Plex Library" (all audiobooks)
- "Rated Books Only" (user-rated books)
- Label: "Base Recommendations On"
7. **Custom Prompt (Optional):**
- Textarea (3-4 rows)
- Label: "Additional Preferences (Optional)"
- Placeholder: "e.g., 'I prefer sci-fi with strong female leads' or 'No romance novels'"
- Help text: "Provide any additional context to personalize recommendations"
8. **Clear All Swipe History:** (visible when configured)
- Button: "Clear Swipe History"
- Confirmation dialog: "This will clear all swipe history and cached recommendations for ALL users. Continue?"
- Action: Clears all users' swipes and recommendations
**Validation:**
- API key required for initial setup
- Model and library scope always required
- API key optional when updating existing configuration (uses saved key)
**Visibility:** BookDate tab visible to all admins in /admin/settings
---
## 5. BookDate Tab Visibility
### Display Rules
- **Show Tab:** Global BookDate configuration exists AND is verified AND is enabled
- Required: Provider, API key, model, library scope configured by admin
- Optional: Custom prompt (can be empty)
- All authenticated users see the tab when these conditions are met
- **Hide Tab:** Configuration missing, unverified, or disabled by admin
### Verification Status
- Stored globally in database (single configuration for all users)
- Re-verification not required on subsequent visits (trust stored config)
- If API call fails during use, show error (don't hide tab)
---
## 6. Recommendation Engine
### 6.1 AI Prompt Generation
**Context Selection Logic:**
- **Max Context Books:** 50 books
- **Context Weighting:**
- If user has ≤50 books in selected scope: Include all
- If user has >50 books in selected scope:
- 40 latest added books (80%)
- 10 latest swipes (20% - includes both left/right swipes for preference learning)
- If user has 0 books in selected scope: Fallback to full library (with warning)
**Prompt Structure (JSON format):**
```json
{
"task": "recommend_audiobooks",
"user_context": {
"library_books": [
{
"title": "Project Hail Mary",
"author": "Andy Weir",
"narrator": "Ray Porter",
"rating": 5
}
// ... up to 40 books
],
"swipe_history": [
{
"title": "The Martian",
"author": "Andy Weir",
"user_action": "requested"
},
{
"title": "Twilight",
"author": "Stephenie Meyer",
"user_action": "rejected"
}
// ... up to 10 swipes
],
"custom_preferences": "User's custom prompt text here (if provided)"
},
"instructions": "Based on the user's library and swipe history, recommend 20 audiobooks they would enjoy. Important rules:\n1. DO NOT recommend any books already in the user's library\n2. DO NOT recommend any books from the swipe history (whether requested, rejected, or dismissed)\n3. Focus on variety and quality\n4. Consider user ratings if available (0-10 scale, higher = liked more)\n5. Learn from rejected books to avoid similar recommendations\n6. Learn from requested books to find similar ones\nReturn ONLY valid JSON with no additional text or formatting.",
"response_format": {
"recommendations": [
{
"title": "string",
"author": "string",
"reason": "1-2 sentence explanation"
}
]
}
}
```
**AI Provider-Specific Adjustments:**
- OpenAI: Use `response_format: { type: "json_object" }` parameter
- Claude: Include "Return ONLY valid JSON, no additional text" in instructions
**Request Count:** Ask for 20 recommendations (expect to filter down to 10 usable)
### 6.2 Recommendation Filtering
**Post-AI Filtering (in order):**
1. **Audnexus Matching:** Match AI recommendation to Audnexus metadata
- If no match: Skip silently, log warning with title/author
2. **Already in Library:** Check against user's Plex library
- If exists: Skip
3. **Already Requested:** Check against user's request history
- If requested: Skip
4. **Already Swiped:** Check against user's swipe history (any direction)
- If swiped: Skip
**Target:** 10 successfully matched and filtered recommendations per batch
### 6.3 Caching Strategy
**Cache Behavior:**
- Store un-swiped recommendations in database per user
- On BookDate tab visit: Check for cached recommendations first
- If cached available: Show cached (no API call)
- If cached <10: Generate new batch to replenish
- If cached =0: Generate new batch
**Cache Invalidation:**
- User swipes on recommendation: Remove from cache
- User changes BookDate settings: Clear all cached recommendations
- Cache never expires (only removed by swipe or settings change)
---
## 7. User Interface
### 7.1 Loading State
**Initial Load (no cached recommendations):**
- Animated loading screen
- Animation: Book cover cards flying/shuffling (whimsical, well-animated)
- Duration: Until recommendations ready (typically 2-5 seconds)
- Text: "Finding your next great listen..."
### 7.2 Recommendation Card
**Card Layout:**
```
┌─────────────────────────────┐
│ │
│ [Cover Image] │
│ (Large) │
│ │
├─────────────────────────────┤
│ Title (Bold, 18px) │
│ Author (Gray, 14px) │
│ Narrator (Gray, 12px) │
├─────────────────────────────┤
│ ⭐ 4.5 (Rating) │
├─────────────────────────────┤
│ Short Description │
│ (3-4 lines, expandable) │
│ [Read more...] │
└─────────────────────────────┘
```
**Field Availability:**
- Title: Always shown
- Author: Always shown
- Cover: Show if available, placeholder if not
- Narrator: Show if available
- Rating: Show if available
- Description: Show if available
### 7.3 Swipe Mechanics
**Mobile (Touch):**
- **Swipe Right:** Request audiobook → Confirmation toast
- **Swipe Left:** Not interested → Next card
- **Swipe Up:** Neutral/dismiss → Next card
- **Visual Feedback:**
- Card follows finger during drag
- Green overlay on right drag
- Red overlay on left drag
- Blue overlay on up drag
- Snap back if drag canceled
**Desktop (Buttons):**
```
┌─────────────────────────────────────┐
│ │
│ [Recommendation Card] │
│ │
└─────────────────────────────────────┘
[❌ Not Interested] [⬆️ Dismiss] [✅ Request]
```
- Buttons positioned below card
- Same actions as mobile swipes
- Keyboard shortcuts (optional enhancement):
- Left arrow: Not interested
- Up arrow: Dismiss
- Right arrow: Request
### 7.4 Request Confirmation Toast
**Trigger:** User swipes right (mobile) or clicks "Request" (desktop)
**Toast Content:**
```
┌─────────────────────────────────────────┐
│ Do you want to request "[Book Title]"? │
│ │
│ Or have you already read/listened │
│ to it elsewhere? │
│ │
│ [Mark as Known] [Request] [Cancel] │
└─────────────────────────────────────────┘
```
**Actions:**
- **Request:** Adds to request queue, records "right" swipe, shows next card
- **Mark as Known:** Records "right" swipe only (no request), shows next card
- **Cancel:** No action, returns to card
**Toast Duration:** Persistent until user chooses (not auto-dismiss)
### 7.5 Undo Functionality
**Left/Up Swipes Only:**
- Small "Undo" button appears briefly (3 seconds) after swipe
- Position: Bottom-left corner
- Action: Restores previous card, removes swipe from history
- UX: Subtle slide-up animation
**Right Swipes:**
- No undo (already confirmed via toast)
### 7.6 Empty State
**Trigger:** User reaches end of cached + newly generated recommendations
**Message:**
```
┌─────────────────────────────────────┐
│ │
│ 🎉 You've seen all our current │
│ recommendations! │
│ │
│ Want more suggestions? │
│ │
│ [Get More] [Go Home] │
└─────────────────────────────────────┘
```
**Actions:**
- **Get More:** Generates new batch of 10 recommendations
- **Go Home:** Redirects to home page
---
## 8. Technical Requirements
### 8.1 Data Models
**BookDateConfig (global singleton - one record for entire system):**
```typescript
{
id: string; // Single record
provider: 'openai' | 'claude';
apiKey: string; // Encrypted at rest (AES-256), shared by all users
model: string; // e.g., 'gpt-4o', 'claude-sonnet-4-5'
libraryScope: 'full' | 'rated';
customPrompt?: string;
isVerified: boolean; // Admin has tested connection
isEnabled: boolean; // Admin-controlled global toggle
createdAt: Date;
updatedAt: Date;
}
// Note: No userId - this is a global configuration managed by admins
```
**BookDateRecommendation (cached):**
```typescript
{
id: string;
userId: string;
batchId: string; // Groups recommendations from same AI call
title: string;
author: string;
narrator?: string;
rating?: number;
description?: string;
coverUrl?: string;
audnexusAsin?: string; // For matching
aiReason: string; // Why AI recommended this
createdAt: Date;
expiresAt?: Date; // NULL = never expires (manual invalidation only)
}
```
**BookDateSwipe (history):**
```typescript
{
id: string;
userId: string;
recommendationId?: string; // NULL if book not from BookDate
bookTitle: string;
bookAuthor: string;
action: 'left' | 'right' | 'up';
markedAsKnown: boolean; // True if user chose "Mark as Known" in toast
createdAt: Date;
}
```
### 8.2 API Endpoints
**Configuration (Admin only - except GET):**
- `POST /api/bookdate/config` - Create/update global BookDate config (Admin only)
- `GET /api/bookdate/config` - Get global BookDate config (excluding API key) (All authenticated users)
- `POST /api/bookdate/test-connection` - Validate API key, return available models (All authenticated users - admins use for setup)
- `DELETE /api/bookdate/config` - Delete global BookDate config (Admin only)
- `DELETE /api/bookdate/swipes` - Clear ALL users' swipe history (Admin only)
**Recommendations (All authenticated users):**
- `GET /api/bookdate/recommendations` - Get current recommendations for user (cached or generate)
- Uses global config but returns personalized recommendations based on user's library/swipes
- Response: Array of 10 recommendations
- `POST /api/bookdate/swipe` - Record swipe action for current user
- Body: `{ recommendationId, action, markedAsKnown? }`
- `POST /api/bookdate/undo` - Undo last swipe (left/up only)
- `POST /api/bookdate/generate` - Force generate new batch (for "Get More" button)
### 8.3 AI Provider Integration
**OpenAI API:**
- Endpoint: `https://api.openai.com/v1/chat/completions`
- Model List: `https://api.openai.com/v1/models`
- Headers: `Authorization: Bearer {apiKey}`
- Response Format: `response_format: { type: "json_object" }`
**Claude (Anthropic) API:**
- Endpoint: `https://api.anthropic.com/v1/messages`
- Model List: `https://api.anthropic.com/v1/models` (or hardcoded list)
- Headers: `x-api-key: {apiKey}`, `anthropic-version: 2023-06-01`
- JSON enforcement: Via system prompt
### 8.4 Audnexus Matching
**Matching Strategy:**
1. Search Audnexus by title + author
2. Fuzzy match if exact match fails (Levenshtein distance <3)
3. If multiple results: Pick best match by popularity/rating
4. If no match: Skip recommendation, log warning
**Data Extraction:**
- Title, Author: From AI response
- Narrator, Rating, Description, Cover: From Audnexus
- ASIN: Store for future reference
### 8.5 Plex Library Integration
**Rated Books Detection:**
- Query Plex API for audiobooks with user ratings (`userRating` field)
- Filter: Only books with `userRating NOT NULL`
- User ratings in Plex use a 0-10 scale
**Full Library:**
- Query all audiobooks (no filter)
---
## 9. Error Handling
### 9.1 Configuration Errors
**Invalid API Key:**
- Show error: "Invalid API key. Please check and try again."
- Don't save configuration
**API Connection Failed:**
- Show error: "Could not connect to {provider}. Check your API key and internet connection."
- Don't save configuration
**Model Fetch Failed:**
- Show error: "Could not fetch available models. Please try again."
- Allow manual model entry (text input) as fallback
### 9.2 Recommendation Generation Errors
**AI API Call Failed:**
- Check for cached recommendations
- If cached available: Show cached
- If no cache: Show error message
```
┌─────────────────────────────────────┐
│ ⚠️ Could not load recommendations │
│ │
│ Error: [Error message] │
│ │
│ [Try Again] [Go to Settings] │
└─────────────────────────────────────┘
```
- Log error details for debugging
**Invalid JSON Response:**
- Log full response for debugging
- Show user-friendly error: "Unexpected response from AI. Please try again."
- Retry once automatically, then show error
**All Recommendations Filtered Out:**
- If <10 recommendations after filtering: Generate additional batch
- If still <10: Show what we have
- If 0 recommendations: Show message
```
┌─────────────────────────────────────┐
│ 🤔 Couldn't find new │
│ recommendations right now. │
│ │
│ Try adjusting your settings or │
│ check back later! │
│ │
│ [Go to Settings] [Go Home] │
└─────────────────────────────────────┘
```
### 9.3 Audnexus Matching Errors
**No Match Found:**
- Skip recommendation silently
- Log: `[BookDate] No Audnexus match: "${title}" by ${author}`
- Continue with next recommendation
**Audnexus API Down:**
- Show error if all 20 recommendations fail to match
- Otherwise: Skip failed matches, show what matched
---
## 10. Security & Privacy
### 10.1 API Key Storage
- **Encryption at rest:** API key encrypted in database using AES-256
- **No logging:** Never log API keys (even in error logs)
- **Admin-managed:** Single global API key configured by admin, shared by all users
- **Secure transmission:** API key never sent to client (stored server-side only)
### 10.2 Per-User Isolation
- All recommendation queries filtered by `userId`
- Users never see other users' swipes, ratings, or recommendations
- Cache is per-user (each user has their own cached recommendations)
- Swipe history is per-user (AI uses individual swipe patterns for personalization)
- Plex library data is per-user (AI sees only the requesting user's library)
### 10.3 Admin Controls
- Global enable/disable toggle (hides BookDate tab for all users when disabled)
- Admin configures single global AI provider/key/model
- Admin can clear all users' swipe history (affects everyone's recommendations)
- Admin can't see decrypted API key after initial save (write-only)
---
## 11. Success Metrics
### Primary Metrics
1. **Adoption Rate:** % of users who complete BookDate setup
2. **Engagement Rate:** % of configured users who visit BookDate tab weekly
3. **Request Conversion:** % of right swipes that become actual requests
4. **Discovery Rate:** % of requests from BookDate vs. manual browsing
### Secondary Metrics
1. **Swipe Distribution:** Ratio of right:left:up swipes (indicates recommendation quality)
2. **Batch Completion Rate:** % of users who swipe through full 10-recommendation batch
3. **Return Rate:** % of users who click "Get More" at end of batch
### Quality Metrics
1. **Audnexus Match Rate:** % of AI recommendations successfully matched
2. **API Error Rate:** % of recommendation requests that fail
3. **Cache Hit Rate:** % of visits served from cache vs. new generation
---
## 12. Future Enhancements (Out of Scope for v1)
1. **Multi-AI Voting:** Query multiple AI models, aggregate recommendations
2. **Social Features:** See what friends are swiping on (opt-in)
3. **Advanced Filtering:** Exclude genres, narrator preferences, length preferences
4. **Recommendation Reasoning:** Show AI's reasoning in card detail view
5. **Listening Goals:** "Find me books under 10 hours" or "Epic fantasy series"
6. **Swipe Analytics:** Personal stats (e.g., "You swipe right on 30% of sci-fi")
---
## 13. Implementation Checklist
### Phase 1: Configuration (Week 1)
- [ ] Database schema (BookDateConfig, BookDateRecommendation, BookDateSwipe)
- [ ] Wizard step UI (skip-able)
- [ ] Settings page section
- [ ] API key encryption
- [ ] OpenAI integration (test connection, fetch models)
- [ ] Claude integration (test connection, fetch models)
- [ ] Admin enable/disable toggle
### Phase 2: Recommendation Engine (Week 2)
- [ ] Context selection logic (40 latest + 10 swipes)
- [ ] AI prompt generation (JSON format)
- [ ] OpenAI API call + JSON parsing
- [ ] Claude API call + JSON parsing
- [ ] Audnexus matching logic
- [ ] Filtering (library, requests, swipes)
- [ ] Caching system (per-user)
### Phase 3: UI/UX (Week 3)
- [ ] BookDate tab (conditional visibility)
- [ ] Loading screen animation
- [ ] Recommendation card component
- [ ] Mobile swipe gestures (left/right/up)
- [ ] Desktop button controls
- [ ] Request confirmation toast
- [ ] Undo button (left/up swipes)
- [ ] Empty state (end of recommendations)
### Phase 4: Integration & Polish (Week 4)
- [ ] Plex library integration (full/listened/rated)
- [ ] Listen percentage calculation (>25%)
- [ ] Request queue integration
- [ ] Error handling (all scenarios)
- [ ] Logging (errors, matches, swipes)
- [ ] Testing (unit, integration, e2e)
- [ ] Documentation update
### Phase 5: Testing & Launch
- [ ] Beta testing with trusted users
- [ ] Monitor error logs
- [ ] Gather feedback on recommendation quality
- [ ] Adjust context weights if needed
- [ ] Production launch
---
## 14. Open Questions
1. **Rate Limiting:** Should we track API usage per user and warn if excessive?
2. **Cost Estimation:** Should we estimate token costs and show users?
3. **Model Recommendations:** Should we suggest specific models based on use case?
4. **Prompt Engineering:** Should we A/B test different prompt formats?
5. **Recommendation Diversity:** Should we force diversity (different genres/authors)?
---
## 15. Appendix
### A. Example AI Prompts
**OpenAI (gpt-4o):**
```json
{
"model": "gpt-4o",
"response_format": { "type": "json_object" },
"messages": [
{
"role": "system",
"content": "You are an expert audiobook recommender. Analyze user's library and preferences to suggest audiobooks they'll love. Return ONLY valid JSON."
},
{
"role": "user",
"content": "{prompt from section 6.1}"
}
]
}
```
**Claude (claude-sonnet-4-5):**
```json
{
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 2048,
"messages": [
{
"role": "user",
"content": "{prompt from section 6.1}\n\nReturn ONLY valid JSON with no additional text or formatting."
}
]
}
```
### B. Database Indexes
**Performance Optimization:**
```sql
CREATE INDEX idx_bookdate_recommendations_user_batch ON BookDateRecommendation(userId, batchId);
CREATE INDEX idx_bookdate_swipes_user_created ON BookDateSwipe(userId, createdAt DESC);
CREATE INDEX idx_bookdate_config_user ON BookDateConfig(userId);
```
### C. Token Estimation
**Average Prompt Size:**
- 40 library books × 100 tokens/book = 4,000 tokens
- 10 swipe history × 20 tokens/swipe = 200 tokens
- Custom prompt: ~100 tokens
- Instructions: ~200 tokens
- **Total Input: ~4,500 tokens**
**Average Response Size:**
- 20 recommendations × 50 tokens/rec = 1,000 tokens
- **Total Output: ~1,000 tokens**
**Cost per Batch (GPT-4o example):**
- Input: 4,500 tokens × $0.005/1k = $0.0225
- Output: 1,000 tokens × $0.015/1k = $0.015
- **Total: ~$0.04 per batch**
---
**End of PRD**
+399
View File
@@ -0,0 +1,399 @@
# 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)
```prisma
- 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)
```prisma
- bookDateLibraryScope ('full' | 'rated', default: 'full')
- bookDateCustomPrompt (optional, max 1000 chars)
- bookDateOnboardingComplete (boolean, default: false)
```
### BookDateRecommendation (cached)
```prisma
- userId, batchId (groups same AI call)
- title, author, narrator, rating, description, coverUrl
- audnexusAsin (for matching/requesting)
- aiReason (why AI recommended)
```
### BookDateSwipe (history)
```prisma
- 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`
## Related
- Full requirements: [features/bookdate-prd.md](bookdate-prd.md)
- Authentication: [backend/services/auth.md](../backend/services/auth.md)
- Database: [backend/database.md](../backend/database.md)
- Setup wizard: [setup-wizard.md](../setup-wizard.md)
- Matching algorithm: [../integrations/plex.md](../integrations/plex.md) (Fixed Issues #7)
+421
View File
@@ -0,0 +1,421 @@
# Chapter Merging Feature
**Status:** ❌ Not Started | Product Requirements Document
## Overview
Automatically merge multi-file audiobook downloads (separate MP3/M4A files per chapter) into a single M4B file with proper chapter markers during file organization.
## Problem Statement
**Current Behavior:**
- Torrents with individual chapter files (e.g., `ch01.mp3`, `ch02.mp3`) are copied as-is
- Results in 10-50+ individual files in Plex library
- Poor playback experience (no chapter navigation, file switching)
- Inconsistent with single-file audiobook standard
**User Impact:**
- Must manually skip between files
- No chapter bookmarks/navigation
- Cluttered library view
- Some audiobook players don't handle multi-file books well
## Solution
Detect multi-file chapter downloads and merge into single M4B with embedded chapters.
## Key Requirements
### Detection Logic
**Chapter File Patterns (auto-detect):**
- Numeric: `01.mp3`, `001.mp3`, `1.mp3`
- Named: `Chapter 1.mp3`, `Chapter 01.mp3`, `Ch1.mp3`, `Ch 01.mp3`
- Part-based: `Part 1.mp3`, `Part01.mp3`
- Combined: `Harry Potter - 01 - Chapter 1.mp3`
**Trigger Conditions:**
- 2+ audio files in download
- Files match chapter naming pattern
- All files same format (m4a, m4b, mp3)
- Feature enabled in config
**Exclusions (do NOT merge):**
- Mixed formats (some MP3, some M4A)
- Non-sequential numbering
- Files without clear chapter indicators
- Single file downloads
### Chapter Metadata Generation
**Chapter Naming Strategy:**
1. **From filename:** Extract "Chapter 1", "01", "Part 1"
2. **Fallback numbering:** "Chapter 1", "Chapter 2" if no name found
3. **Preserve order:** Sort files naturally (ch1, ch2, ch10)
**Chapter Timing:**
- Calculate from individual file durations using ffprobe
- Format: FFMETADATA1 standard
- Timestamps in milliseconds
**Example:**
```
;FFMETADATA1
[CHAPTER]
TIMEBASE=1/1000
START=0
END=2700000
title=Chapter 1: The Beginning
[CHAPTER]
TIMEBASE=1/1000
START=2700000
END=5400000
title=Chapter 2: The Journey
```
### FFmpeg Implementation
**For M4A/M4B files (same format, no re-encode):**
```bash
# 1. Create concat list
echo "file '/path/ch01.m4a'" > filelist.txt
echo "file '/path/ch02.m4a'" >> filelist.txt
# 2. Generate chapter metadata
# [Create chapters.txt with timing from durations]
# 3. Merge with chapters
ffmpeg -f concat -safe 0 -i filelist.txt \
-i chapters.txt \
-map_metadata 1 \
-codec copy \
-metadata title="Book Title" \
-metadata album="Book Title" \
-metadata album_artist="Author" \
-metadata artist="Author" \
-metadata composer="Narrator" \
-metadata date="2024" \
-f mp4 \
output.m4b
```
**For MP3 files (requires conversion):**
```bash
# Must re-encode to M4B (AAC)
ffmpeg -f concat -safe 0 -i filelist.txt \
-i chapters.txt \
-map_metadata 1 \
-codec:a aac -b:a 128k \ # Quality preservation
-metadata title="Book Title" \
# ... (same metadata)
-f mp4 \
output.m4b
```
**Quality Settings (MP3 → M4B):**
- Bitrate: 128kbps AAC (transparent for audiobooks, 64kbps minimum)
- Sampling rate: Match source (44.1kHz or 48kHz)
- Channels: Preserve mono/stereo
### File Naming
**Output filename:**
```
[Author]/[Title] ([Year])/[Title].m4b
```
**Cover art:** Extract from first file or download from Audible (existing logic)
### Configuration
**New config keys:**
- `chapter_merging_enabled` (boolean, default: false)
- `chapter_merging_mp3_bitrate` (string, default: "128k")
- `chapter_merging_delete_originals` (boolean, default: true - after successful merge)
**Settings UI (Admin → Paths tab):**
```
☐ Merge multi-file chapter downloads into single M4B
↳ Audio quality for MP3 conversion: [128kbps ▼]
↳ ☑ Delete original chapter files after merge
```
**Setup wizard (Paths step):**
- Checkbox: "Merge chapter files" (default: unchecked)
- Tooltip: "Combines separate chapter files into single audiobook with chapter markers"
## User Experience
### Success Flow
1. Download completes: 25 chapter MP3 files
2. File organization starts
3. System detects chapter pattern
4. Merges files with progress logging:
- "Detected 25 chapter files, merging into single M4B..."
- "Processing chapter 1/25..."
- "Merge complete: BookTitle.m4b (15.2 GB, 25 chapters)"
5. Copies merged M4B to target directory
6. Deletes temp files and originals (if configured)
7. Plex scans single M4B with full chapter navigation
### Fallback Flow
**If merge fails:**
1. Log error: "Chapter merge failed: [reason]"
2. Fall back to current behavior: copy individual files
3. Mark request as "available" (not failed)
4. User can manually merge later
**Failure scenarios:**
- FFmpeg crash/timeout
- Insufficient disk space for temp file
- Corrupted source files
- Unsupported audio codec
## Technical Implementation
### File: `src/lib/utils/chapter-merger.ts`
**Exports:**
```typescript
interface ChapterFile {
path: string;
filename: string;
duration: number; // seconds
chapterName: string; // extracted from filename
}
interface MergeOptions {
title: string;
author: string;
narrator?: string;
year?: number;
outputPath: string;
mp3Bitrate?: string; // default: "128k"
}
interface MergeResult {
success: boolean;
outputPath?: string;
chapterCount?: number;
duration?: number; // total seconds
error?: string;
}
// Main functions
async function detectChapterFiles(files: string[]): Promise<boolean>;
async function sortChapterFiles(files: string[]): Promise<ChapterFile[]>;
async function getAudioDuration(filePath: string): Promise<number>;
async function generateChapterMetadata(chapters: ChapterFile[]): Promise<string>;
async function mergeChapters(chapters: ChapterFile[], options: MergeOptions): Promise<MergeResult>;
```
### Integration Points
**File: `src/lib/utils/file-organizer.ts`**
**Modify `organize()` method:**
```typescript
// After finding audiobook files (line ~73)
if (audioFiles.length > 1) {
const config = await prisma.configuration.findUnique({
where: { key: 'chapter_merging_enabled' }
});
const mergingEnabled = config?.value === 'true';
const isChapterDownload = await detectChapterFiles(audioFiles);
if (mergingEnabled && isChapterDownload) {
// Merge chapters instead of copying individually
const mergeResult = await mergeChapters(audioFiles, {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
year: audiobook.year,
outputPath: path.join(targetPath, `${audiobook.title}.m4b`)
});
if (mergeResult.success) {
result.audioFiles = [mergeResult.outputPath];
result.filesMovedCount = 1;
// Skip individual file copying
} else {
// Fallback to individual file copying
await logger?.warn(`Chapter merge failed, copying files individually`);
// Continue with existing logic
}
}
}
```
### Database Schema
**No changes required** - uses existing `Configuration` table
### Dependencies
**Already available:**
- ffmpeg (installed in Docker images)
- ffprobe (for duration detection)
## Edge Cases & Error Handling
### Edge Cases
| Scenario | Behavior |
|----------|----------|
| Mixed formats (MP3 + M4A) | Skip merge, copy individually |
| Non-sequential numbering (1, 3, 5) | Attempt merge, log warning |
| Duplicate chapter numbers | Sort by filename, log warning |
| Very large file count (100+ chapters) | Continue merge, increase timeout |
| Missing chapters (1, 2, 4) | Merge available, log warning |
| Single chapter file | Skip merge (not a multi-file book) |
| No chapter indicators | Skip merge, copy individually |
### Error Handling
**Disk space checks:**
- Estimate merged file size (sum of source files + 10% overhead)
- Check available space before merge
- Fail gracefully if insufficient space
**Timeouts:**
- Set timeout based on file count and size
- Default: 5 minutes + (1 minute per chapter)
- Log progress every 10 chapters
**Cleanup:**
- Always remove temp concat lists
- Remove temp merged file on failure
- Keep original files if merge fails
## Performance Considerations
### Processing Time Estimates
**M4A/M4B merge (no re-encode):**
- 10 chapters: ~30 seconds
- 25 chapters: ~1 minute
- 50 chapters: ~2 minutes
**MP3 → M4B conversion:**
- 10 hours audiobook: ~5-10 minutes (depends on CPU)
- Real-time encoding speed varies by hardware
### Resource Usage
- **CPU:** High during MP3 conversion, low for M4A copy
- **Disk:** Requires space for temp merged file (= sum of source files)
- **Memory:** Low (streaming processing)
### Optimization
- Process in background job (already async)
- Don't block other downloads
- Limit concurrent merges (1 at a time recommended)
## Testing Strategy
### Test Cases
1. **M4A chapter files (20 files)**
- Verify merge succeeds
- Verify chapter count matches file count
- Verify metadata preserved
- Verify chapter navigation works in Plex
2. **MP3 chapter files (15 files)**
- Verify conversion to M4B
- Verify audio quality (bitrate ~128kbps)
- Verify no audio glitches at chapter boundaries
3. **Mixed formats**
- Verify merge skipped
- Verify fallback to individual files
4. **Failed merge**
- Verify fallback behavior
- Verify original files preserved
- Verify request marked available (not failed)
5. **Chapter naming**
- "Ch1.mp3" → "Chapter 1"
- "001 - Introduction.mp3" → "Introduction"
- "Part 1.mp3" → "Part 1"
6. **Edge cases**
- Single file: no merge
- 100+ chapters: successful merge
- Missing chapters (gaps): successful merge with warning
## Success Metrics
### Functional
- ✅ Successful merge rate > 95% (for valid chapter downloads)
- ✅ Chapter navigation works in Plex
- ✅ Zero audio quality degradation (M4A copy mode)
- ✅ Fallback works 100% of time on merge failure
### Performance
- ✅ M4A merge: < 2 minutes for 25 chapters
- ✅ MP3 conversion: < 15 minutes for 10-hour audiobook
- ✅ No impact on concurrent downloads
### User Experience
- ✅ Feature opt-in (default disabled)
- ✅ Clear logging of merge progress
- ✅ Single file in Plex instead of dozens
- ✅ Proper chapter markers in audiobook players
## Implementation Phases
### Phase 1: Core Functionality (MVP)
- [ ] Implement `chapter-merger.ts` utility
- [ ] Detection logic (chapter file patterns)
- [ ] Natural sorting algorithm
- [ ] Duration extraction (ffprobe)
- [ ] Chapter metadata generation (FFMETADATA1)
- [ ] M4A/M4B merge (codec copy mode)
- [ ] Integration with file-organizer.ts
- [ ] Configuration keys in database
### Phase 2: MP3 Support
- [ ] MP3 → M4B conversion logic
- [ ] Quality preservation settings
- [ ] Bitrate configuration UI
### Phase 3: UI & Polish
- [ ] Setup wizard integration
- [ ] Admin settings UI (Paths tab)
- [ ] Progress logging improvements
- [ ] Error messaging UX
### Phase 4: Advanced Features (Future)
- [ ] Custom chapter naming from file metadata
- [ ] Chapter art extraction (if embedded in files)
- [ ] Preview merged file before finalizing
- [ ] Manual chapter editing UI
## Related Documentation
- [File Organization](../phase3/file-organization.md) - File copying and tagging
- [Metadata Tagging](../phase3/file-organization.md#metadata-tagging) - Current tagging system
- [Background Jobs](../backend/services/jobs.md) - Job processing system
- [Configuration](../backend/services/config.md) - Settings management
## Open Questions
1. **Chapter naming strategy:** Should we try to extract from embedded metadata first, or always use filename?
2. **MP3 default behavior:** Should MP3 merging be opt-in separately (slower, lossy)?
3. **Parallel processing:** Merge multiple books at once, or serialize?
4. **Preview mode:** Let users review chapter detection before merge?
5. **Retry logic:** Auto-retry failed merges with different settings?
## References
- FFmpeg concat demuxer: https://trac.ffmpeg.org/wiki/Concatenate
- FFmpeg metadata: https://ffmpeg.org/ffmpeg-formats.html#Metadata-1
- M4B format spec: ISO/IEC 14496-12 (MPEG-4 Part 12)
- Natural sorting: https://en.wikipedia.org/wiki/Natural_sort_order