mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
-321
@@ -1,321 +0,0 @@
|
||||
# Contributing to ReadMeABook
|
||||
|
||||
Thank you for your interest in contributing to ReadMeABook! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 How to Contribute
|
||||
|
||||
### Reporting Issues
|
||||
|
||||
If you encounter a bug or have a feature request:
|
||||
|
||||
1. **Check existing issues** - Search [GitHub Issues](https://github.com/kikootwo/ReadMeABook/issues) to see if it's already reported
|
||||
2. **Create a new issue** - Use the appropriate issue template
|
||||
3. **Provide details** - Include:
|
||||
- Clear description of the problem/feature
|
||||
- Steps to reproduce (for bugs)
|
||||
- Expected vs actual behavior
|
||||
- Environment details (OS, Docker version, etc.)
|
||||
- Relevant logs or screenshots
|
||||
|
||||
### Submitting Pull Requests
|
||||
|
||||
1. **Fork the repository**
|
||||
2. **Create a feature branch** from `main`:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
3. **Make your changes** following our coding standards
|
||||
4. **Test your changes** thoroughly
|
||||
5. **Commit with clear messages**:
|
||||
```bash
|
||||
git commit -m "Add: brief description of changes"
|
||||
```
|
||||
6. **Push to your fork**:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
7. **Open a Pull Request** with:
|
||||
- Clear title and description
|
||||
- Reference to related issues
|
||||
- Screenshots/demos if applicable
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
### Local Development
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/kikootwo/ReadMeABook.git
|
||||
cd ReadMeABook
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Set up environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your local configuration
|
||||
```
|
||||
|
||||
4. **Start development stack:**
|
||||
```bash
|
||||
# Using Docker Compose (recommended)
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# Or run services separately
|
||||
docker compose -f docker-compose.debug.yml up -d postgres redis
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. **Run database migrations:**
|
||||
```bash
|
||||
npm run prisma:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
6. **Access the app:**
|
||||
- App: http://localhost:3030
|
||||
- Prisma Studio: `npm run prisma:studio`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Coding Standards
|
||||
|
||||
### General Guidelines
|
||||
|
||||
- **Follow existing code style** - Use the project's ESLint configuration
|
||||
- **Write clear, descriptive variable names**
|
||||
- **Add comments for complex logic**
|
||||
- **Keep functions small and focused**
|
||||
- **Test your changes**
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Define proper types (avoid `any`)
|
||||
- Use interfaces for object shapes
|
||||
- Export types when they're reusable
|
||||
|
||||
### React Components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Keep components focused and reusable
|
||||
- Use proper TypeScript props typing
|
||||
- Follow the existing component structure in `src/components/`
|
||||
|
||||
### File Organization
|
||||
|
||||
- **Max 300-400 lines per file** - Refactor if larger
|
||||
- **Add file headers** to reference documentation:
|
||||
```typescript
|
||||
/**
|
||||
* Component: Feature Name
|
||||
* Documentation: documentation/path/to/doc.md
|
||||
*/
|
||||
```
|
||||
|
||||
### Database Changes
|
||||
|
||||
- Always use Prisma migrations
|
||||
- Test migrations with seed data
|
||||
- Document schema changes in pull request
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Updating Documentation
|
||||
|
||||
When making changes that affect documentation:
|
||||
|
||||
1. **Update relevant docs** in `documentation/`
|
||||
2. **Use token-efficient format** (see [CLAUDE.md](CLAUDE.md))
|
||||
3. **Update TABLEOFCONTENTS.md** if adding new docs
|
||||
4. **Keep docs in sync** with code changes
|
||||
|
||||
### Documentation Standards
|
||||
|
||||
- Use bullet points over prose
|
||||
- Include code examples where helpful
|
||||
- Keep status indicators updated (✅/⏳/❌)
|
||||
- Link to related documentation
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Before Submitting
|
||||
|
||||
- Test locally with Docker Compose
|
||||
- Verify no console errors
|
||||
- Test with clean database (migrations)
|
||||
- Check responsive design (if UI changes)
|
||||
- Verify all features still work
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Login with Plex works
|
||||
- [ ] Library scan completes
|
||||
- [ ] Book requests can be created
|
||||
- [ ] Settings can be updated
|
||||
- [ ] Background jobs run correctly
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Code Review Process
|
||||
|
||||
### What We Look For
|
||||
|
||||
- **Functionality** - Does it work as intended?
|
||||
- **Code quality** - Is it clean and maintainable?
|
||||
- **Testing** - Has it been adequately tested?
|
||||
- **Documentation** - Are docs updated?
|
||||
- **Breaking changes** - Are they necessary and documented?
|
||||
|
||||
### Review Timeline
|
||||
|
||||
- Initial review: Within 1-2 weeks
|
||||
- Follow-up on feedback: Ongoing
|
||||
- Merge: When approved and CI passes
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Release Process
|
||||
|
||||
### Versioning
|
||||
|
||||
We follow [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR**: Breaking changes
|
||||
- **MINOR**: New features (backward compatible)
|
||||
- **PATCH**: Bug fixes
|
||||
|
||||
### Release Cycle
|
||||
|
||||
- Releases are tagged as needed
|
||||
- Docker images automatically built on push to `main`
|
||||
- Breaking changes documented in release notes
|
||||
|
||||
---
|
||||
|
||||
## 💡 Development Tips
|
||||
|
||||
### Working with Prisma
|
||||
|
||||
```bash
|
||||
# Generate Prisma client after schema changes
|
||||
npm run prisma:generate
|
||||
|
||||
# Push schema changes to database
|
||||
npm run db:push
|
||||
|
||||
# Open Prisma Studio
|
||||
npm run prisma:studio
|
||||
```
|
||||
|
||||
### Working with Docker
|
||||
|
||||
```bash
|
||||
# Build local image
|
||||
docker compose -f docker-compose.local.yml build
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yml logs -f
|
||||
|
||||
# Reset database
|
||||
docker compose -f docker-compose.local.yml down -v
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
- Use `LOG_LEVEL=debug` in environment
|
||||
- Check browser console for frontend issues
|
||||
- Use Prisma Studio to inspect database
|
||||
- Check Docker logs for backend issues
|
||||
|
||||
---
|
||||
|
||||
## 📋 Commit Message Guidelines
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<type>: <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- **feat**: New feature
|
||||
- **fix**: Bug fix
|
||||
- **docs**: Documentation changes
|
||||
- **style**: Code style changes (formatting)
|
||||
- **refactor**: Code refactoring
|
||||
- **test**: Adding/updating tests
|
||||
- **chore**: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat: Add support for multiple audiobook formats
|
||||
|
||||
Implements support for M4A, M4B, and FLAC formats in addition to MP3.
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
```
|
||||
fix: Resolve Plex authentication timeout issue
|
||||
|
||||
Increases timeout and adds retry logic for slow Plex servers.
|
||||
|
||||
Fixes #456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Areas We Need Help
|
||||
|
||||
- [ ] Additional audiobook format support
|
||||
- [ ] Enhanced torrent ranking algorithm
|
||||
- [ ] Mobile UI improvements
|
||||
- [ ] Internationalization (i18n)
|
||||
- [ ] Additional integration options
|
||||
- [ ] Performance optimization
|
||||
- [ ] Test coverage
|
||||
- [ ] Documentation improvements
|
||||
|
||||
---
|
||||
|
||||
## 💬 Community
|
||||
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/kikootwo/ReadMeABook/discussions)
|
||||
- **Issues**: [GitHub Issues](https://github.com/kikootwo/ReadMeABook/issues)
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
Every contribution, no matter how small, makes ReadMeABook better. Thank you for taking the time to contribute!
|
||||
@@ -1,339 +1,159 @@
|
||||
# 📚 ReadMeABook
|
||||
# ReadMeABook
|
||||
|
||||
**An automated audiobook request and acquisition system that integrates with your Plex library.**
|
||||
**[HERO SCREENSHOT PLACEHOLDER: Full-width hero image showing the main dashboard with recent requests, BookDate swipe interface preview, and library stats - something that looks modern and shows off the UI]**
|
||||
|
||||
ReadMeABook bridges the gap between your Plex audiobook library and automation tools like qBittorrent and Prowlarr. Request audiobooks through a web interface, and let ReadMeABook handle finding, downloading, and organizing them into your Plex library.
|
||||
An audiobook automation system that connects your Plex library to torrent and Usenet indexers. Request audiobooks through a web interface and they're automatically downloaded, organized, and imported into your Plex library.
|
||||
|
||||
---
|
||||
## What It Does
|
||||
|
||||
## ✨ Features
|
||||
If you're running Plex with an audiobook library, you know the drill: search for torrents or NZBs manually, download them, move files to the right folder, wait for Plex to scan. ReadMeABook automates all of that.
|
||||
|
||||
- **🔐 Plex Authentication** - Seamless login with your existing Plex account
|
||||
- **📖 Library Sync** - Automatically scans and tracks your Plex audiobook library
|
||||
- **🤖 AI-Powered Recommendations** - BookDate: Get personalized audiobook suggestions based on your library and preferences
|
||||
- **🔍 Smart Search** - Finds audiobooks via Audible metadata and Prowlarr indexers
|
||||
- **⚡ Automated Downloads** - Integrates with qBittorrent for automatic acquisition
|
||||
- **📊 Request Management** - Track request status from search to library import
|
||||
- **👥 Multi-User Support** - Role-based access control (user/admin)
|
||||
- **🎯 Intelligent Matching** - Matches downloaded files to requested books
|
||||
- **🔄 Background Jobs** - Automated library scans, status checks, and cleanup
|
||||
It works like the *arr apps (Sonarr, Radarr) but for audiobooks. Connect it to Prowlarr for searching, qBittorrent or SABnzbd for downloading, and Plex for your library. Request a book and everything else happens automatically.
|
||||
|
||||
---
|
||||
There's also BookDate - an AI-powered recommendation system that suggests audiobooks based on what you already own. Think Tinder but for books. Swipe right to request, left to skip.
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Features
|
||||
|
||||
### Prerequisites
|
||||
- **Docker** (recommended) or Docker Compose
|
||||
- **Plex Media Server** with an audiobook library
|
||||
- **qBittorrent** - Download client for torrent management
|
||||
- **Prowlarr** - Indexer aggregator for searching torrents
|
||||
- **Plex Integration** - OAuth login, automatic library scanning, fuzzy matching
|
||||
- **Torrent Support** - qBittorrent and Transmission clients
|
||||
- **Usenet Support** - SABnzbd for NZB downloads
|
||||
- **Prowlarr Integration** - Search both torrents and Usenet indexers
|
||||
- **Request Management** - Track downloads from search to completion
|
||||
- **BookDate Recommendations** - AI-powered suggestions with swipe interface (OpenAI/Claude)
|
||||
- **Chapter Merging** - Automatically combine multi-file downloads into single M4B with chapter markers
|
||||
- **E-book Sidecar** - Optional e-book downloads from Anna's Archive
|
||||
- **Multi-User Support** - Role-based access (admin/user), request approval system
|
||||
- **Setup Wizard** - 9-step guided configuration with connection testing
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
## Screenshots
|
||||
|
||||
1. **Download the compose file:**
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/kikootwo/ReadMeABook/main/docker-compose.yml
|
||||
```
|
||||
**[SCREENSHOT PLACEHOLDER: Dashboard page showing active requests with status badges (Searching/Downloading/Completed) and recent activity]**
|
||||
|
||||
2. **Start the container:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
**[SCREENSHOT PLACEHOLDER: BookDate interface with the card stack showing a book cover, AI reasoning, and the swipe gesture indicators]**
|
||||
|
||||
3. **Access the application:**
|
||||
Open http://localhost:3030 in your browser
|
||||
**[SCREENSHOT PLACEHOLDER: Settings page showing the Prowlarr/qBittorrent/SABnzbd configuration form with connection test buttons]**
|
||||
|
||||
> **Note:** The application automatically creates all required directories on first run.
|
||||
## Quick Start
|
||||
|
||||
### Option 2: Docker Run
|
||||
Prerequisites: Docker, Plex Media Server, and either qBittorrent or SABnzbd. Prowlarr is highly recommended for searching indexers.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name readmeabook \
|
||||
-p 3030:3030 \
|
||||
-v ./config:/app/config \
|
||||
-v ./cache:/app/cache \
|
||||
-v ./downloads:/downloads \
|
||||
-v ./media:/media \
|
||||
-v readmeabook-pgdata:/var/lib/postgresql/data \
|
||||
-v readmeabook-redis:/var/lib/redis \
|
||||
ghcr.io/kikootwo/readmeabook:latest
|
||||
```
|
||||
### Docker Compose
|
||||
|
||||
> **Note:** Directories are automatically created on first run.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Volume Mounts
|
||||
|
||||
| Path | Description | Required |
|
||||
|------|-------------|----------|
|
||||
| `/app/config` | Application configuration and logs | Yes |
|
||||
| `/app/cache` | Temporary file cache | Yes |
|
||||
| `/downloads` | qBittorrent download directory | Yes |
|
||||
| `/media` | Plex audiobook library path | Yes |
|
||||
| `/var/lib/postgresql/data` | PostgreSQL database | Yes |
|
||||
| `/var/lib/redis` | Redis cache data | Yes |
|
||||
|
||||
> **💡 Tip:** The unified Docker image includes PostgreSQL and Redis built-in. For separate containers, see [docker-compose.debug.yml](docker-compose.debug.yml).
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Initial Setup
|
||||
|
||||
After starting ReadMeABook for the first time:
|
||||
|
||||
1. **Navigate to** http://localhost:3030
|
||||
2. **Log in with Plex** - First user automatically becomes admin
|
||||
3. **Configure Settings** (Settings → Configuration):
|
||||
- **Plex Server URL** - Your Plex server address
|
||||
- **Audiobook Library** - Select your audiobook library
|
||||
- **Prowlarr API** - API URL and key for torrent searching
|
||||
- **qBittorrent** - Web UI URL and credentials for downloads
|
||||
4. **Scan Library** - Click "Scan Library" to import existing audiobooks
|
||||
5. **Explore BookDate** - Get AI-powered audiobook recommendations
|
||||
6. **Start Requesting** - Search for audiobooks and submit requests
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables (Optional)
|
||||
|
||||
Most variables have secure defaults generated automatically. Configure these only if needed:
|
||||
|
||||
#### Security (Auto-generated on first run)
|
||||
- `JWT_SECRET` - JWT token signing secret
|
||||
- `JWT_REFRESH_SECRET` - Refresh token signing secret
|
||||
- `CONFIG_ENCRYPTION_KEY` - Database encryption key
|
||||
- `POSTGRES_PASSWORD` - PostgreSQL password
|
||||
|
||||
#### Application
|
||||
- `PUBLIC_URL` - Your public URL (e.g., `https://readmeabook.example.com`)
|
||||
- `LOG_LEVEL` - Logging level: `debug`, `info`, `warn`, `error` (default: `info`)
|
||||
- `PLEX_CLIENT_IDENTIFIER` - Custom Plex client ID (auto-generated if not set)
|
||||
|
||||
#### Database
|
||||
- `POSTGRES_USER` - Database user (default: `readmeabook`)
|
||||
- `POSTGRES_DB` - Database name (default: `readmeabook`)
|
||||
|
||||
**Generate secure secrets:**
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
Example Nginx configuration:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name readmeabook.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3030;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Don't forget to set the `PUBLIC_URL` environment variable in your docker-compose.yml:
|
||||
```yaml
|
||||
environment:
|
||||
PUBLIC_URL: "https://readmeabook.example.com"
|
||||
services:
|
||||
readmeabook:
|
||||
image: ghcr.io/kikootwo/readmeabook:latest
|
||||
container_name: readmeabook
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3030:3030"
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./cache:/app/cache
|
||||
- ./downloads:/downloads
|
||||
- ./media:/media
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
- ./redis:/var/lib/redis
|
||||
environment:
|
||||
# Optional - set to your user/group ID for proper file permissions
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
|
||||
# Required if accessing from outside localhost (for Plex OAuth)
|
||||
PUBLIC_URL: "https://audiobooks.example.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updating
|
||||
Update the volume paths to match your setup:
|
||||
- `/downloads` should point to your download client's directory
|
||||
- `/media` should point to your Plex audiobook library
|
||||
|
||||
Start it:
|
||||
```bash
|
||||
# Pull latest image
|
||||
docker compose pull
|
||||
|
||||
# Restart with new image
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Resource Usage
|
||||
|
||||
The unified container typically uses:
|
||||
- **Memory:** ~500MB-1GB (depending on usage)
|
||||
- **CPU:** Low (spikes during library scans and downloads)
|
||||
- **Disk:** Varies based on database size and Redis cache
|
||||
- **Image Size:** ~3GB (includes PostgreSQL 16 + Redis + App)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
# Check logs for errors
|
||||
docker logs readmeabook
|
||||
|
||||
# Check container status
|
||||
docker ps -a | grep readmeabook
|
||||
```
|
||||
|
||||
### Database issues
|
||||
```bash
|
||||
# Access PostgreSQL directly
|
||||
docker exec -it readmeabook su - postgres -c "psql -h 127.0.0.1 -U readmeabook"
|
||||
|
||||
# Check database status
|
||||
docker exec readmeabook su - postgres -c "pg_isready -h 127.0.0.1"
|
||||
```
|
||||
|
||||
### Redis issues
|
||||
```bash
|
||||
# Test Redis connection
|
||||
docker exec readmeabook redis-cli ping
|
||||
# Should return: PONG
|
||||
```
|
||||
|
||||
### Reset everything (⚠️ Warning: Deletes all data)
|
||||
```bash
|
||||
# Stop and remove container
|
||||
docker compose down
|
||||
|
||||
# Remove volumes
|
||||
docker volume rm readmeabook-pgdata readmeabook-redis
|
||||
|
||||
# Start fresh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
Navigate to http://localhost:3030 and run through the setup wizard.
|
||||
|
||||
## 📦 Backup & Restore
|
||||
### Initial Configuration
|
||||
|
||||
### Backup Database
|
||||
```bash
|
||||
docker exec readmeabook su - postgres -c \
|
||||
"pg_dump -h 127.0.0.1 -U readmeabook readmeabook" > backup.sql
|
||||
```
|
||||
The setup wizard walks you through:
|
||||
1. Admin account creation
|
||||
2. Plex server connection and library selection
|
||||
3. Prowlarr configuration (API key and indexer selection)
|
||||
4. Download client setup (qBittorrent or SABnzbd)
|
||||
5. Path configuration with validation
|
||||
6. Optional BookDate AI recommendations
|
||||
7. Review and finalization
|
||||
|
||||
### Restore Database
|
||||
```bash
|
||||
cat backup.sql | docker exec -i readmeabook su - postgres -c \
|
||||
"psql -h 127.0.0.1 -U readmeabook readmeabook"
|
||||
```
|
||||
After setup, the first library scan runs automatically. You can start requesting audiobooks immediately.
|
||||
|
||||
## How It Works
|
||||
|
||||
**Request Flow:**
|
||||
1. Search for an audiobook (pulls metadata from Audible)
|
||||
2. Submit a request
|
||||
3. Prowlarr searches your configured indexers (torrents and/or NZBs)
|
||||
4. Best result is selected based on seeders, file size, and quality
|
||||
5. Download starts in qBittorrent or SABnzbd
|
||||
6. Files are monitored until completion
|
||||
7. Multi-file audiobooks are optionally merged into M4B with chapter markers
|
||||
8. Files are organized into your Plex library using configurable folder templates
|
||||
9. Plex scans and matches the audiobook
|
||||
10. Request marked as complete
|
||||
|
||||
**BookDate Flow:**
|
||||
1. Configure AI provider in settings (OpenAI or Claude)
|
||||
2. Set your library scope (full library, rated books only, or pick favorites)
|
||||
3. AI analyzes your library and suggests recommendations
|
||||
4. Swipe right to request, left to skip, up to dismiss
|
||||
5. Requests are processed like normal requests
|
||||
|
||||
The system runs background jobs for library scanning, Audible metadata refresh, and request status checks. Everything is logged and visible in the admin dashboard.
|
||||
|
||||
## Architecture
|
||||
|
||||
Built with Next.js, PostgreSQL, and Redis. The container includes all three services in a single unified image. Background jobs are handled by Bull queues with Redis.
|
||||
|
||||
Authentication uses Plex OAuth, so users log in with their existing Plex accounts. The first user automatically becomes an admin. Admins can enable a request approval system if needed.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration happens in the web UI. The only environment variables you might need are:
|
||||
- `PUBLIC_URL` - Required for OAuth redirects if not using localhost
|
||||
- `PUID`/`PGID` - Optional, for file permission mapping
|
||||
- `LOG_LEVEL` - Optional, defaults to `info`
|
||||
|
||||
Secrets (JWT keys, database password, encryption keys) are auto-generated on first run and persisted to `/app/config/.secrets`.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
**Chapter Merging:** When enabled, multi-file audiobook downloads (separate MP3/M4A files per chapter) are automatically merged into a single M4B file with proper chapter markers. Works for both M4A (fast, codec copy) and MP3 (re-encoded to AAC).
|
||||
|
||||
**E-book Sidecar:** Optionally downloads e-books from Anna's Archive to accompany audiobooks. Files are placed in the same folder as the audiobook. Supports EPUB, PDF, MOBI, and AZW3 formats.
|
||||
|
||||
**Request Approval:** Admins can enable a request approval system where user requests must be approved before processing. Useful for managing indexer limits or controlling library growth.
|
||||
|
||||
**Remote Path Mapping:** If your download client runs on a different machine or container, path mapping ensures ReadMeABook can find completed downloads.
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a bug? Want to add a feature? Pull requests are welcome. The codebase is documented in the `documentation/` directory.
|
||||
|
||||
If you want to discuss ideas or get help, join the Discord: **[DISCORD LINK PLACEHOLDER]**
|
||||
|
||||
## Support
|
||||
|
||||
If this project is useful to you, the best way to support it is:
|
||||
- Star the repo
|
||||
- Share it with others who might find it useful
|
||||
- Contribute code, documentation, or bug reports
|
||||
|
||||
Financial contributions are not expected or necessary. This project exists because I wanted it for myself, and sharing it costs nothing extra.
|
||||
|
||||
## License
|
||||
|
||||
MIT - See LICENSE file
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Development
|
||||
|
||||
For local development and debugging, see:
|
||||
- **Local Build:** [docker-compose.local.yml](docker-compose.local.yml)
|
||||
- **Debug Mode:** [docker-compose.debug.yml](docker-compose.debug.yml) (separate PostgreSQL/Redis containers)
|
||||
- **Documentation:** [documentation/](documentation/)
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
readmeabook/
|
||||
├── src/
|
||||
│ ├── app/ # Next.js app router pages
|
||||
│ ├── components/ # React components
|
||||
│ ├── lib/ # Utilities and helpers
|
||||
│ ├── services/ # Backend services (auth, jobs, config)
|
||||
│ └── generated/ # Prisma client
|
||||
├── prisma/
|
||||
│ └── schema.prisma # Database schema
|
||||
├── documentation/ # Project documentation
|
||||
├── docker/ # Docker configuration
|
||||
└── public/ # Static assets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆚 Deployment Options
|
||||
|
||||
### Unified Container (Default - docker-compose.yml)
|
||||
**✅ Best for:** Simple deployment, single-host, minimal configuration
|
||||
|
||||
- All services in one container (PostgreSQL + Redis + App)
|
||||
- Easiest to deploy and manage
|
||||
- Single container to update
|
||||
- ~3GB image size
|
||||
|
||||
### Multi-Container (docker-compose.debug.yml)
|
||||
**✅ Best for:** Development, debugging, separate service scaling
|
||||
|
||||
- PostgreSQL, Redis, and App as separate containers
|
||||
- Independent service management
|
||||
- Better for development and testing
|
||||
- More flexible but requires more configuration
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Best Practices
|
||||
|
||||
1. **Change default secrets** in production (set environment variables)
|
||||
2. **Use HTTPS** via reverse proxy (Nginx, Caddy, Traefik)
|
||||
3. **Restrict port access** - only expose port 3030 to trusted networks
|
||||
4. **Keep container updated** - pull latest images regularly
|
||||
5. **Backup database** - regularly backup PostgreSQL data
|
||||
6. **Review user access** - Manage user roles appropriately
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Full Documentation:** [documentation/](documentation/)
|
||||
- **Table of Contents:** [documentation/TABLEOFCONTENTS.md](documentation/TABLEOFCONTENTS.md)
|
||||
- **Agent Guidelines:** [AGENTS.md](AGENTS.md)
|
||||
- **Claude Guidelines:** [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **Plex** - Media server platform
|
||||
- **Prowlarr** - Indexer manager
|
||||
- **qBittorrent** - BitTorrent client
|
||||
- **Next.js** - React framework
|
||||
- **Prisma** - Database ORM
|
||||
- **PostgreSQL** - Database
|
||||
- **Redis** - Cache and job queue
|
||||
|
||||
---
|
||||
|
||||
## 📧 Support
|
||||
|
||||
- **Issues:** [GitHub Issues](https://github.com/kikootwo/ReadMeABook/issues)
|
||||
- **Discussions:** [GitHub Discussions](https://github.com/kikootwo/ReadMeABook/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for audiobook enthusiasts**
|
||||
Built for people who want their audiobook library to just work.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AddCachedLibraryCoverPath
|
||||
ALTER TABLE "plex_library" ADD COLUMN "cached_library_cover_path" TEXT;
|
||||
@@ -45,7 +45,8 @@ model User {
|
||||
registrationStatus String? @default("approved") @map("registration_status") // 'pending_approval' | 'approved' | 'rejected'
|
||||
|
||||
// BookDate per-user preferences
|
||||
bookDateLibraryScope String? @default("full") @map("bookdate_library_scope") // 'full' | 'rated'
|
||||
bookDateLibraryScope String? @default("full") @map("bookdate_library_scope") // 'full' | 'rated' | 'favorites'
|
||||
bookDateFavoriteBookIds String? @map("bookdate_favorite_book_ids") @db.Text // JSON array of PlexLibrary IDs (max 25)
|
||||
bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text
|
||||
bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete")
|
||||
|
||||
@@ -130,8 +131,9 @@ model PlexLibrary {
|
||||
isbn String? // ISBN (10 or 13) - for additional matching capability
|
||||
|
||||
// File information
|
||||
filePath String? @map("file_path") @db.Text
|
||||
thumbUrl String? @map("thumb_url") @db.Text // Plex thumbnail URL
|
||||
filePath String? @map("file_path") @db.Text
|
||||
thumbUrl String? @map("thumb_url") @db.Text // Plex thumbnail URL
|
||||
cachedLibraryCoverPath String? @map("cached_library_cover_path") @db.Text // Local path to cached library cover image
|
||||
|
||||
// Plex metadata
|
||||
plexLibraryId String @map("plex_library_id") // Which Plex library contains this
|
||||
|
||||
@@ -163,6 +163,7 @@ export const validateAuthSettings = (settings: Settings): { valid: boolean; mess
|
||||
export const getTabValidation = (
|
||||
activeTab: SettingsTab,
|
||||
settings: Settings,
|
||||
originalSettings: Settings | null,
|
||||
validated: {
|
||||
plex: boolean;
|
||||
audiobookshelf: boolean;
|
||||
@@ -179,7 +180,15 @@ export const getTabValidation = (
|
||||
case 'auth':
|
||||
return validated.oidc || validated.registration;
|
||||
case 'prowlarr':
|
||||
return validated.prowlarr;
|
||||
// Only require validation if URL or API key changed
|
||||
// If only indexers/flags changed, allow saving without test
|
||||
if (!originalSettings) return validated.prowlarr;
|
||||
|
||||
const prowlarrConnectionChanged =
|
||||
settings.prowlarr.url !== originalSettings.prowlarr.url ||
|
||||
settings.prowlarr.apiKey !== originalSettings.prowlarr.apiKey;
|
||||
|
||||
return prowlarrConnectionChanged ? validated.prowlarr : true;
|
||||
case 'download':
|
||||
return validated.download;
|
||||
case 'paths':
|
||||
|
||||
@@ -49,7 +49,9 @@ export default function AdminSettings() {
|
||||
|
||||
// Indexer-specific state (used by IndexersTab)
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SavedIndexerConfig[]>([]);
|
||||
const [originalConfiguredIndexers, setOriginalConfiguredIndexers] = useState<SavedIndexerConfig[]>([]);
|
||||
const [flagConfigs, setFlagConfigs] = useState<IndexerFlagConfig[]>([]);
|
||||
const [originalFlagConfigs, setOriginalFlagConfigs] = useState<IndexerFlagConfig[]>([]);
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
@@ -89,7 +91,9 @@ export default function AdminSettings() {
|
||||
const response = await fetchWithAuth('/api/admin/settings/prowlarr/indexers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setFlagConfigs(data.flagConfigs || []);
|
||||
const flags = data.flagConfigs || [];
|
||||
setFlagConfigs(flags);
|
||||
setOriginalFlagConfigs(JSON.parse(JSON.stringify(flags)));
|
||||
|
||||
// Extract configured indexers (enabled ones)
|
||||
const configured = (data.indexers || [])
|
||||
@@ -103,6 +107,7 @@ export default function AdminSettings() {
|
||||
categories: idx.categories || [3030],
|
||||
}));
|
||||
setConfiguredIndexers(configured);
|
||||
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configured)));
|
||||
} else {
|
||||
console.error('Failed to fetch indexers:', response.status);
|
||||
if (force) {
|
||||
@@ -139,6 +144,13 @@ export default function AdminSettings() {
|
||||
await saveTabSettings(activeTab, settings, configuredIndexers, flagConfigs);
|
||||
setMessage({ type: 'success', text: 'Settings saved successfully!' });
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)));
|
||||
|
||||
// Also update original indexers and flag configs when saving prowlarr tab
|
||||
if (activeTab === 'prowlarr') {
|
||||
setOriginalConfiguredIndexers(JSON.parse(JSON.stringify(configuredIndexers)));
|
||||
setOriginalFlagConfigs(JSON.parse(JSON.stringify(flagConfigs)));
|
||||
}
|
||||
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
@@ -161,8 +173,21 @@ export default function AdminSettings() {
|
||||
|
||||
// Dynamic tabs, validation, and change detection
|
||||
const tabs = getTabs(settings.backendMode);
|
||||
const currentTabValidation = getTabValidation(activeTab, settings, validated);
|
||||
const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
const currentTabValidation = getTabValidation(activeTab, settings, originalSettings, validated);
|
||||
|
||||
// Check for unsaved changes in settings and indexer-specific state
|
||||
const hasUnsavedChanges = (() => {
|
||||
const settingsChanged = JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
|
||||
// For prowlarr tab, also check indexers and flag configs
|
||||
if (activeTab === 'prowlarr') {
|
||||
const indexersChanged = JSON.stringify(configuredIndexers) !== JSON.stringify(originalConfiguredIndexers);
|
||||
const flagConfigsChanged = JSON.stringify(flagConfigs) !== JSON.stringify(originalFlagConfigs);
|
||||
return settingsChanged || indexersChanged || flagConfigsChanged;
|
||||
}
|
||||
|
||||
return settingsChanged;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@@ -315,7 +340,9 @@ export default function AdminSettings() {
|
||||
</Button>
|
||||
{!currentTabValidation && hasUnsavedChanges && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 self-center">
|
||||
Please test the connection before saving
|
||||
{activeTab === 'prowlarr'
|
||||
? 'Please test the Prowlarr connection before saving'
|
||||
: 'Please test the connection before saving'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: BookDate Library API
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.BookDate.Library');
|
||||
|
||||
/**
|
||||
* GET /api/bookdate/library
|
||||
* Get user's full library for book picker modal
|
||||
* Returns: id, title, author, coverUrl (thumbnail)
|
||||
*/
|
||||
async function getLibraryBooks(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Get library ID based on backend mode
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
let libraryId: string;
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const absLibraryId = await configService.get('audiobookshelf.library_id');
|
||||
if (!absLibraryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Audiobookshelf library ID configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
libraryId = absLibraryId;
|
||||
} else {
|
||||
// Plex mode
|
||||
const plexConfig = await configService.getPlexConfig();
|
||||
if (!plexConfig.libraryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No Plex library ID configured' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
libraryId = plexConfig.libraryId;
|
||||
}
|
||||
|
||||
// Fetch ALL books from library (no limit - client handles pagination/infinite scroll)
|
||||
// Join with AudibleCache to get cached cover images
|
||||
const books = await prisma.plexLibrary.findMany({
|
||||
where: { plexLibraryId: libraryId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
asin: true, // For joining with AudibleCache
|
||||
cachedLibraryCoverPath: true, // For library cached covers
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Fetched ${books.length} books from library for user ${userId}`);
|
||||
|
||||
// Get ASINs for books that have them
|
||||
const asins = books.map(b => b.asin).filter((asin): asin is string => !!asin);
|
||||
|
||||
// Fetch cached covers from AudibleCache (only for books with ASINs)
|
||||
const cachedCovers = await prisma.audibleCache.findMany({
|
||||
where: {
|
||||
asin: { in: asins },
|
||||
},
|
||||
select: {
|
||||
asin: true,
|
||||
coverArtUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create ASIN -> coverUrl map
|
||||
const coverMap = new Map<string, string>();
|
||||
cachedCovers.forEach(cache => {
|
||||
if (cache.coverArtUrl) {
|
||||
coverMap.set(cache.asin, cache.coverArtUrl);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Found ${coverMap.size} cached covers out of ${asins.length} books with ASINs`);
|
||||
|
||||
// Map books with their covers (priority: library cache > Audible cache > null)
|
||||
return NextResponse.json({
|
||||
books: books.map(book => {
|
||||
let coverUrl: string | null = null;
|
||||
|
||||
// Priority 1: Library cached cover (most books should have this)
|
||||
if (book.cachedLibraryCoverPath) {
|
||||
const filename = book.cachedLibraryCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/library/${filename}`;
|
||||
}
|
||||
// Priority 2: Audible cache (fallback for books with ASIN but no library cache)
|
||||
else if (book.asin && coverMap.has(book.asin)) {
|
||||
coverUrl = coverMap.get(book.asin)!;
|
||||
}
|
||||
// Priority 3: null (show placeholder)
|
||||
|
||||
return {
|
||||
id: book.id,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Get library books error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to fetch library books' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return requireAuth(req, getLibraryBooks);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ async function getPreferences(req: AuthenticatedRequest) {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateFavoriteBookIds: true,
|
||||
bookDateCustomPrompt: true,
|
||||
bookDateOnboardingComplete: true,
|
||||
},
|
||||
@@ -49,6 +50,7 @@ async function getPreferences(req: AuthenticatedRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
libraryScope: effectiveScope,
|
||||
favoriteBookIds: user.bookDateFavoriteBookIds ? JSON.parse(user.bookDateFavoriteBookIds) : [],
|
||||
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
|
||||
onboardingComplete: user.bookDateOnboardingComplete || false,
|
||||
backendCapabilities: {
|
||||
@@ -75,12 +77,28 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
|
||||
// Parse request body
|
||||
const body = await req.json();
|
||||
const { libraryScope, customPrompt, onboardingComplete } = body;
|
||||
const { libraryScope, favoriteBookIds, customPrompt, onboardingComplete } = body;
|
||||
|
||||
// Validate library scope
|
||||
if (libraryScope && !['full', 'rated'].includes(libraryScope)) {
|
||||
if (libraryScope && !['full', 'rated', 'favorites'].includes(libraryScope)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid library scope. Must be "full" or "rated"' },
|
||||
{ error: 'Invalid library scope. Must be "full", "rated", or "favorites"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate favorites scope requirements
|
||||
if (libraryScope === 'favorites' && (!favoriteBookIds || favoriteBookIds.length === 0)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Favorites scope requires at least 1 favorite book selected' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate favorite books limit
|
||||
if (favoriteBookIds && favoriteBookIds.length > 25) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Maximum 25 favorite books allowed' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -110,6 +128,12 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
if (libraryScope !== undefined) {
|
||||
updateData.bookDateLibraryScope = libraryScope || 'full';
|
||||
}
|
||||
if (favoriteBookIds !== undefined) {
|
||||
// Store as JSON string
|
||||
updateData.bookDateFavoriteBookIds = favoriteBookIds && favoriteBookIds.length > 0
|
||||
? JSON.stringify(favoriteBookIds)
|
||||
: null;
|
||||
}
|
||||
if (customPrompt !== undefined) {
|
||||
// Normalize empty strings to null for consistency
|
||||
const normalizedPrompt = (typeof customPrompt === 'string' && customPrompt.trim()) ? customPrompt.trim() : null;
|
||||
@@ -125,6 +149,7 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
data: updateData,
|
||||
select: {
|
||||
bookDateLibraryScope: true,
|
||||
bookDateFavoriteBookIds: true,
|
||||
bookDateCustomPrompt: true,
|
||||
bookDateOnboardingComplete: true,
|
||||
},
|
||||
@@ -133,6 +158,7 @@ async function updatePreferences(req: AuthenticatedRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
libraryScope: updatedUser.bookDateLibraryScope || 'full',
|
||||
favoriteBookIds: updatedUser.bookDateFavoriteBookIds ? JSON.parse(updatedUser.bookDateFavoriteBookIds) : [],
|
||||
customPrompt: updatedUser.bookDateCustomPrompt || '', // Always return empty string for UI
|
||||
onboardingComplete: updatedUser.bookDateOnboardingComplete || false,
|
||||
});
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Component: Library Cover Cache API Route
|
||||
* Documentation: documentation/features/library-thumbnail-cache.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.LibraryCovers');
|
||||
|
||||
const LIBRARY_CACHE_DIR = '/app/cache/library';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Validate filename (prevent directory traversal)
|
||||
if (!filename || filename.includes('..') || filename.includes('/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid filename' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(LIBRARY_CACHE_DIR, filename);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'File not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
|
||||
// Determine content type based on extension
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
const contentType = contentTypeMap[ext] || 'application/octet-stream';
|
||||
|
||||
// Return the image with appropriate headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error serving library cover', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -8,7 +8,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { RecommendationCard } from '@/components/bookdate/RecommendationCard';
|
||||
import { CardStack } from '@/components/bookdate/CardStack';
|
||||
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';
|
||||
import { SettingsWidget } from '@/components/bookdate/SettingsWidget';
|
||||
|
||||
@@ -150,13 +150,8 @@ export default function BookDatePage() {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Move to next recommendation
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
|
||||
// Check if we need to load more recommendations
|
||||
if (currentIndex + 1 >= recommendations.length) {
|
||||
// At the end - could auto-load more or show empty state
|
||||
}
|
||||
// Note: currentIndex is now incremented in handleSwipeComplete
|
||||
// after animations finish
|
||||
|
||||
} catch (error) {
|
||||
console.error('Swipe error:', error);
|
||||
@@ -164,6 +159,16 @@ export default function BookDatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipeComplete = () => {
|
||||
// Increment currentIndex after animations complete
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
|
||||
// Check if we need to load more recommendations
|
||||
if (currentIndex + 1 >= recommendations.length) {
|
||||
// At the end - could auto-load more or show empty state
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
if (!lastSwipe || lastSwipe.action === 'right') {
|
||||
return;
|
||||
@@ -323,8 +328,6 @@ export default function BookDatePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const currentRec = recommendations[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<Header />
|
||||
@@ -347,10 +350,12 @@ export default function BookDatePage() {
|
||||
{currentIndex + 1} / {recommendations.length}
|
||||
</div>
|
||||
|
||||
{/* Recommendation card */}
|
||||
<RecommendationCard
|
||||
recommendation={currentRec}
|
||||
{/* Card Stack */}
|
||||
<CardStack
|
||||
recommendations={recommendations}
|
||||
currentIndex={currentIndex}
|
||||
onSwipe={handleSwipe}
|
||||
onSwipeComplete={handleSwipeComplete}
|
||||
/>
|
||||
|
||||
{/* Undo button */}
|
||||
|
||||
@@ -39,3 +39,134 @@ body {
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* BookDate Card Stack Animations */
|
||||
|
||||
/* Exit animations - card swipes away */
|
||||
@keyframes card-exit-left {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-150%, 50px) rotate(-25deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-exit-right {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(150%, 50px) rotate(25deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-exit-up {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, -120%) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Advance animations - cards move forward in stack */
|
||||
@keyframes card-advance-to-top {
|
||||
0% {
|
||||
transform: scale(0.95) translateY(-12px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-advance-to-middle {
|
||||
0% {
|
||||
transform: scale(0.90) translateY(-24px);
|
||||
opacity: 0.90;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95) translateY(-12px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enter animation - new card enters from bottom of stack */
|
||||
@keyframes card-enter {
|
||||
0% {
|
||||
transform: scale(0.85) translateY(-36px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.90) translateY(-24px);
|
||||
opacity: 0.90;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.animate-exit-left {
|
||||
animation: card-exit-left 400ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-exit-right {
|
||||
animation: card-exit-right 400ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-exit-up {
|
||||
animation: card-exit-up 400ms ease-in-out forwards;
|
||||
}
|
||||
|
||||
.animate-advance-to-top {
|
||||
animation: card-advance-to-top 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-advance-to-middle {
|
||||
animation: card-advance-to-middle 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-enter {
|
||||
animation: card-enter 350ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
/* Stack positioning classes */
|
||||
.card-stack-position-0 {
|
||||
z-index: 50;
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.card-stack-position-1 {
|
||||
z-index: 40;
|
||||
transform: scale(0.95) translateY(-12px);
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-stack-position-2 {
|
||||
z-index: 30;
|
||||
transform: scale(0.90) translateY(-24px);
|
||||
opacity: 0.90;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.card-stack-container {
|
||||
perspective: 1000px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.card-stack-item {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Component: BookDate Book Picker Modal
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface BookPickerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedIds: string[];
|
||||
onConfirm: (selectedIds: string[]) => void;
|
||||
maxSelection: number;
|
||||
}
|
||||
|
||||
interface LibraryBook {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string | null;
|
||||
}
|
||||
|
||||
export function BookPickerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedIds,
|
||||
onConfirm,
|
||||
maxSelection,
|
||||
}: BookPickerModalProps) {
|
||||
const [books, setBooks] = useState<LibraryBook[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [localSelectedIds, setLocalSelectedIds] = useState<string[]>(selectedIds);
|
||||
|
||||
// Infinite scroll state
|
||||
const [displayedCount, setDisplayedCount] = useState(100); // Start with 100 books
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load library books when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadLibraryBooks();
|
||||
setLocalSelectedIds(selectedIds); // Reset to initial selection when reopening
|
||||
setDisplayedCount(100); // Reset displayed count
|
||||
setSearchQuery(''); // Reset search
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadLibraryBooks = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
const response = await fetch('/api/bookdate/library', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load library books');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setBooks(data.books || []);
|
||||
} catch (error: any) {
|
||||
console.error('Load library books error:', error);
|
||||
setError(error.message || 'Failed to load library books');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBook = (bookId: string) => {
|
||||
setLocalSelectedIds(prev => {
|
||||
if (prev.includes(bookId)) {
|
||||
// Deselect
|
||||
return prev.filter(id => id !== bookId);
|
||||
} else {
|
||||
// Select (only if under max)
|
||||
if (prev.length < maxSelection) {
|
||||
return [...prev, bookId];
|
||||
}
|
||||
return prev; // Already at max
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Reset displayed count when search query changes
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setDisplayedCount(100); // Reset to show first 100 results
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(localSelectedIds);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setLocalSelectedIds(selectedIds); // Reset to original
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Filter books by search query
|
||||
const filteredBooks = books.filter(book => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
book.title.toLowerCase().includes(query) ||
|
||||
book.author.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Only display a subset for performance (infinite scroll)
|
||||
const displayedBooks = filteredBooks.slice(0, displayedCount);
|
||||
const hasMore = displayedCount < filteredBooks.length;
|
||||
|
||||
const isMaxReached = localSelectedIds.length >= maxSelection;
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
const currentFilteredLength = filteredBooks.length;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !loading) {
|
||||
// Load more books when bottom sentinel is visible
|
||||
setDisplayedCount(prev => Math.min(prev + 100, currentFilteredLength));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
if (currentTarget) {
|
||||
observer.observe(currentTarget);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentTarget) {
|
||||
observer.unobserve(currentTarget);
|
||||
}
|
||||
};
|
||||
}, [loading, filteredBooks.length]); // Re-run when loading state or filtered length changes
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 transition-opacity"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl bg-white dark:bg-gray-800 rounded-xl shadow-2xl z-50 max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Select Your Favorite Books
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Choose up to {maxSelection} books that represent your favorites
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selection Counter */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-sm font-medium ${isMaxReached ? 'text-orange-600 dark:text-orange-400' : 'text-blue-600 dark:text-blue-400'}`}>
|
||||
{localSelectedIds.length} / {maxSelection} selected
|
||||
{isMaxReached && (
|
||||
<span className="ml-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
(Maximum reached)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{localSelectedIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setLocalSelectedIds([])}
|
||||
className="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 font-medium px-2 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Search books..."
|
||||
className="w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Books Grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredBooks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? 'No books match your search' : 'No books in your library'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{displayedBooks.map((book, index) => {
|
||||
const isSelected = localSelectedIds.includes(book.id);
|
||||
const isDisabled = !isSelected && isMaxReached;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={book.id}
|
||||
onClick={() => !isDisabled && toggleBook(book.id)}
|
||||
disabled={isDisabled}
|
||||
className={`group relative aspect-[2/3] rounded-lg overflow-hidden transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'ring-4 ring-blue-500 shadow-lg scale-105'
|
||||
: isDisabled
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: 'hover:scale-105 hover:shadow-md'
|
||||
}`}
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
animation: 'fadeIn 0.3s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Cover Image or Text Placeholder */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
|
||||
{book.coverUrl ? (
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
alt={book.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-3">
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 line-clamp-4 mb-1">
|
||||
{book.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{book.author}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Overlay */}
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-blue-600/20 flex items-center justify-center">
|
||||
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Book Info on Hover */}
|
||||
{!isSelected && !isDisabled && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="text-white text-xs font-medium line-clamp-2">
|
||||
{book.title}
|
||||
</div>
|
||||
<div className="text-white/80 text-xs line-clamp-1 mt-1">
|
||||
{book.author}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{!loading && !error && hasMore && (
|
||||
<div ref={observerTarget} className="flex justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show count info */}
|
||||
{!loading && !error && filteredBooks.length > 0 && (
|
||||
<div className="text-center py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {displayedBooks.length} of {filteredBooks.length} books
|
||||
{filteredBooks.length !== books.length && ` (filtered from ${books.length} total)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={localSelectedIds.length === 0}
|
||||
className="flex-1 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Confirm Selection ({localSelectedIds.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Component: BookDate Card Stack
|
||||
* Documentation: documentation/features/bookdate-animations.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { RecommendationCard } from './RecommendationCard';
|
||||
|
||||
interface CardStackProps {
|
||||
recommendations: any[];
|
||||
currentIndex: number;
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
onSwipeComplete: () => void;
|
||||
}
|
||||
|
||||
export function CardStack({
|
||||
recommendations,
|
||||
currentIndex,
|
||||
onSwipe,
|
||||
onSwipeComplete,
|
||||
}: CardStackProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [exitDirection, setExitDirection] = useState<'left' | 'right' | 'up' | null>(null);
|
||||
const [isAdvancing, setIsAdvancing] = useState(false);
|
||||
|
||||
// Reset animation states when currentIndex changes externally (e.g., undo)
|
||||
useEffect(() => {
|
||||
setIsExiting(false);
|
||||
setExitDirection(null);
|
||||
setIsAdvancing(false);
|
||||
}, [currentIndex]);
|
||||
|
||||
const handleSwipeStart = useCallback(
|
||||
(action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => {
|
||||
// Prevent swipes during animation
|
||||
if (isExiting || isAdvancing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start exit animation
|
||||
setIsExiting(true);
|
||||
setExitDirection(action);
|
||||
|
||||
// Call parent's onSwipe (for API call)
|
||||
onSwipe(action, markedAsKnown);
|
||||
|
||||
// Wait for exit animation to complete (400ms)
|
||||
setTimeout(() => {
|
||||
setIsExiting(false);
|
||||
setExitDirection(null);
|
||||
|
||||
// Start advance animation
|
||||
setIsAdvancing(true);
|
||||
|
||||
// Wait for advance animation to complete (350ms)
|
||||
setTimeout(() => {
|
||||
setIsAdvancing(false);
|
||||
// Notify parent that animations are complete
|
||||
onSwipeComplete();
|
||||
}, 350);
|
||||
}, 400);
|
||||
},
|
||||
[isExiting, isAdvancing, onSwipe, onSwipeComplete]
|
||||
);
|
||||
|
||||
// Get up to 3 cards to display
|
||||
const visibleCards = [];
|
||||
|
||||
if (isAdvancing) {
|
||||
// During advance, skip the card that just exited (at currentIndex)
|
||||
// Show cards at indices: currentIndex+1, currentIndex+2, currentIndex+3
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const index = currentIndex + 1 + i;
|
||||
if (index < recommendations.length) {
|
||||
visibleCards.push({
|
||||
recommendation: recommendations[index],
|
||||
index,
|
||||
stackPosition: i, // Target position (0, 1, 2)
|
||||
fromPosition: i + 1, // Source position for animation (1, 2, 3)
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal rendering: show current card and next 2
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const index = currentIndex + i;
|
||||
if (index < recommendations.length) {
|
||||
visibleCards.push({
|
||||
recommendation: recommendations[index],
|
||||
index,
|
||||
stackPosition: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no cards, return null
|
||||
if (visibleCards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-stack-container relative w-full max-w-md h-[calc(80vh)] md:h-[calc(85vh)]">
|
||||
{visibleCards.map((card, arrayIndex) => {
|
||||
const isTopCard = card.stackPosition === 0;
|
||||
const isExitingCard = isTopCard && isExiting;
|
||||
|
||||
// Determine animation class
|
||||
let animationClass = '';
|
||||
if (isExitingCard && exitDirection) {
|
||||
animationClass = `animate-exit-${exitDirection}`;
|
||||
} else if (isAdvancing && card.fromPosition !== undefined) {
|
||||
// Cards are advancing from their previous position
|
||||
if (card.fromPosition === 1) {
|
||||
animationClass = 'animate-advance-to-top'; // 1 → 0
|
||||
} else if (card.fromPosition === 2) {
|
||||
animationClass = 'animate-advance-to-middle'; // 2 → 1
|
||||
} else if (card.fromPosition === 3) {
|
||||
animationClass = 'animate-enter'; // 3 → 2 (new card)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine static position class (when not animating)
|
||||
const positionClass = !animationClass
|
||||
? `card-stack-position-${card.stackPosition}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.index}
|
||||
className={`card-stack-item absolute inset-0 ${positionClass} ${animationClass}`}
|
||||
style={{
|
||||
// Ensure proper stacking even without animation
|
||||
zIndex: 50 - card.stackPosition * 10,
|
||||
}}
|
||||
>
|
||||
<RecommendationCard
|
||||
recommendation={card.recommendation}
|
||||
onSwipe={handleSwipeStart}
|
||||
stackPosition={card.stackPosition}
|
||||
isAnimating={isExiting || isAdvancing}
|
||||
isDraggable={isTopCard && !isExiting && !isAdvancing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,17 @@ import { useSwipeable } from 'react-swipeable';
|
||||
interface RecommendationCardProps {
|
||||
recommendation: any;
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
stackPosition?: number; // 0 = top, 1 = middle, 2 = bottom
|
||||
isAnimating?: boolean; // True during exit/advance animations
|
||||
isDraggable?: boolean; // False for cards behind the top card
|
||||
}
|
||||
|
||||
export function RecommendationCard({
|
||||
recommendation,
|
||||
onSwipe,
|
||||
stackPosition = 0,
|
||||
isAnimating = false,
|
||||
isDraggable = true,
|
||||
}: RecommendationCardProps) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
@@ -36,9 +42,18 @@ export function RecommendationCard({
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwiping: (eventData) => {
|
||||
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
|
||||
// Only update drag offset if card is draggable and not animating
|
||||
if (isDraggable && !isAnimating) {
|
||||
setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
|
||||
}
|
||||
},
|
||||
onSwiped: (eventData) => {
|
||||
// Only process swipe if card is draggable and not animating
|
||||
if (!isDraggable || isAnimating) {
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check final position when user releases - must be at 100px threshold
|
||||
const finalX = eventData.deltaX;
|
||||
const finalY = eventData.deltaY;
|
||||
@@ -187,27 +202,32 @@ export function RecommendationCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop buttons */}
|
||||
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onSwipe('left')}
|
||||
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg"
|
||||
>
|
||||
❌ Not Interested
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSwipe('up')}
|
||||
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg"
|
||||
>
|
||||
⬆️ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSwipeRight}
|
||||
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg"
|
||||
>
|
||||
✅ Request
|
||||
</button>
|
||||
</div>
|
||||
{/* Desktop buttons - only show for top card */}
|
||||
{stackPosition === 0 && (
|
||||
<div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => !isAnimating && onSwipe('left')}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
❌ Not Interested
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !isAnimating && onSwipe('up')}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
⬆️ Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !isAnimating && handleSwipeRight()}
|
||||
disabled={isAnimating}
|
||||
className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full font-medium transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
✅ Request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Toast */}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BookPickerModal } from './BookPickerModal';
|
||||
|
||||
interface SettingsWidgetProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +16,9 @@ interface SettingsWidgetProps {
|
||||
}
|
||||
|
||||
export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboardingComplete }: SettingsWidgetProps) {
|
||||
const [libraryScope, setLibraryScope] = useState<'full' | 'rated'>('full');
|
||||
const [libraryScope, setLibraryScope] = useState<'full' | 'rated' | 'favorites'>('full');
|
||||
const [favoriteBookIds, setFavoriteBookIds] = useState<string[]>([]);
|
||||
const [showBookPicker, setShowBookPicker] = useState(false);
|
||||
const [customPrompt, setCustomPrompt] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -52,6 +55,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
|
||||
const data = await response.json();
|
||||
setLibraryScope(data.libraryScope || 'full');
|
||||
setFavoriteBookIds(data.favoriteBookIds || []);
|
||||
setCustomPrompt(data.customPrompt || '');
|
||||
setBackendCapabilities(data.backendCapabilities || { supportsRatings: true });
|
||||
} catch (error: any) {
|
||||
@@ -63,6 +67,12 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate favorites scope
|
||||
if (libraryScope === 'favorites' && favoriteBookIds.length === 0) {
|
||||
setError('Please select at least 1 favorite book');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
@@ -78,6 +88,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
},
|
||||
body: JSON.stringify({
|
||||
libraryScope,
|
||||
favoriteBookIds: libraryScope === 'favorites' ? favoriteBookIds : undefined,
|
||||
customPrompt: trimmedPrompt || null, // Send null if empty
|
||||
onboardingComplete: isOnboarding ? true : undefined,
|
||||
}),
|
||||
@@ -179,7 +190,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
name="libraryScope"
|
||||
value="full"
|
||||
checked={libraryScope === 'full'}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated' | 'favorites')}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
@@ -200,7 +211,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
name="libraryScope"
|
||||
value="rated"
|
||||
checked={libraryScope === 'rated'}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated')}
|
||||
onChange={(e) => setLibraryScope(e.target.value as 'full' | 'rated' | 'favorites')}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
@@ -214,19 +225,52 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Show info message if ratings not supported */}
|
||||
{!backendCapabilities.supportsRatings && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
Note: Your backend does not support user ratings. Only "Full Library" scope is available.
|
||||
{/* Pick My Favorites */}
|
||||
<label className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50 ${libraryScope === 'favorites' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="libraryScope"
|
||||
value="favorites"
|
||||
checked={libraryScope === 'favorites'}
|
||||
onChange={(e) => {
|
||||
setLibraryScope('favorites');
|
||||
setShowBookPicker(true); // Auto-open book picker
|
||||
}}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Pick my favorites
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Select up to 25 books as your personalized library
|
||||
{favoriteBookIds.length > 0 && (
|
||||
<span className="ml-2 text-blue-600 dark:text-blue-400 font-medium">
|
||||
({favoriteBookIds.length} selected)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{libraryScope === 'favorites' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowBookPicker(true);
|
||||
}}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
{favoriteBookIds.length > 0 ? 'Change Selection' : 'Choose Books'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="customPrompt" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Custom Prompt Modifier
|
||||
Special Requests
|
||||
<span className="text-gray-500 dark:text-gray-400 font-normal ml-2">
|
||||
(Optional)
|
||||
</span>
|
||||
@@ -268,6 +312,15 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Book Picker Modal */}
|
||||
<BookPickerModal
|
||||
isOpen={showBookPicker}
|
||||
onClose={() => setShowBookPicker(false)}
|
||||
selectedIds={favoriteBookIds}
|
||||
onConfirm={(ids) => setFavoriteBookIds(ids)}
|
||||
maxSelection={25}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+93
-15
@@ -232,12 +232,12 @@ async function enrichWithUserRatings(
|
||||
/**
|
||||
* Get user's Plex library books based on scope
|
||||
* @param userId - User ID
|
||||
* @param scope - 'full' | 'listened' | 'rated'
|
||||
* @param scope - 'full' | 'listened' | 'rated' | 'favorites'
|
||||
* @returns Array of library books (max 40)
|
||||
*/
|
||||
export async function getUserLibraryBooks(
|
||||
userId: string,
|
||||
scope: 'full' | 'listened' | 'rated'
|
||||
scope: 'full' | 'listened' | 'rated' | 'favorites'
|
||||
): Promise<LibraryBook[]> {
|
||||
try {
|
||||
const configService = getConfigService();
|
||||
@@ -249,6 +249,74 @@ export async function getUserLibraryBooks(
|
||||
scope = 'full';
|
||||
}
|
||||
|
||||
// Handle favorites scope
|
||||
if (scope === 'favorites') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { bookDateFavoriteBookIds: true },
|
||||
});
|
||||
|
||||
const favoriteIds = user?.bookDateFavoriteBookIds
|
||||
? JSON.parse(user.bookDateFavoriteBookIds)
|
||||
: [];
|
||||
|
||||
if (favoriteIds.length === 0) {
|
||||
logger.warn('Favorites scope selected but no favorites stored, falling back to full library');
|
||||
scope = 'full';
|
||||
} else {
|
||||
// Get library ID for filtering
|
||||
let libraryId: string;
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const absLibraryId = await configService.get('audiobookshelf.library_id');
|
||||
if (!absLibraryId) {
|
||||
logger.warn('No Audiobookshelf library ID configured');
|
||||
return [];
|
||||
}
|
||||
libraryId = absLibraryId;
|
||||
} else {
|
||||
const plexConfig = await configService.getPlexConfig();
|
||||
if (!plexConfig.libraryId) {
|
||||
logger.warn('No Plex library ID configured');
|
||||
return [];
|
||||
}
|
||||
libraryId = plexConfig.libraryId;
|
||||
}
|
||||
|
||||
// Query favorite books
|
||||
const cachedBooks = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
id: { in: favoriteIds },
|
||||
plexLibraryId: libraryId, // Ensure books are from current library
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
plexGuid: true,
|
||||
plexRatingKey: true,
|
||||
userRating: true,
|
||||
},
|
||||
orderBy: { addedAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Fetched ${cachedBooks.length} favorite books for user ${userId}`);
|
||||
|
||||
// For Plex: Enrich with user's personal ratings
|
||||
// For Audiobookshelf: Skip enrichment (no rating support)
|
||||
if (backendMode === 'plex') {
|
||||
return await enrichWithUserRatings(userId, cachedBooks);
|
||||
} else {
|
||||
// Audiobookshelf: Map to LibraryBook without ratings
|
||||
return cachedBooks.map(book => ({
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
rating: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get library ID based on backend mode
|
||||
let libraryId: string;
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
@@ -422,7 +490,7 @@ export async function buildAIPrompt(
|
||||
): Promise<string> {
|
||||
const libraryBooks = await getUserLibraryBooks(
|
||||
userId,
|
||||
config.libraryScope as 'full' | 'listened' | 'rated'
|
||||
config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites'
|
||||
);
|
||||
|
||||
const swipeHistory = await getUserRecentSwipes(userId, 10);
|
||||
@@ -434,6 +502,27 @@ export async function buildAIPrompt(
|
||||
libraryScope: config.libraryScope,
|
||||
});
|
||||
|
||||
let instructions =
|
||||
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
||||
'CRITICAL RULES:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
|
||||
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||
'4. Focus on variety across genres, authors, and styles\n' +
|
||||
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'6. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'7. Learn from requested books to find similar ones\n' +
|
||||
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context';
|
||||
|
||||
// Add special instruction for favorites scope
|
||||
if (config.libraryScope === 'favorites') {
|
||||
instructions += '\n\n' +
|
||||
'IMPORTANT: The user has specifically handpicked these ' + libraryBooks.length + ' books as their personal favorites. ' +
|
||||
'These represent their preferred genres, authors, themes, and styles. Use these as PRIMARY INSPIRATION for your recommendations. ' +
|
||||
'Find books that capture the essence of what makes these favorites special to the user.';
|
||||
}
|
||||
|
||||
const prompt = {
|
||||
task: 'recommend_audiobooks',
|
||||
user_context: {
|
||||
@@ -447,18 +536,7 @@ export async function buildAIPrompt(
|
||||
})),
|
||||
custom_preferences: config.customPrompt || null,
|
||||
},
|
||||
instructions:
|
||||
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
|
||||
'CRITICAL RULES:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' +
|
||||
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||
'4. Focus on variety across genres, authors, and styles\n' +
|
||||
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'6. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'7. Learn from requested books to find similar ones\n' +
|
||||
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context',
|
||||
instructions,
|
||||
};
|
||||
|
||||
const promptString = JSON.stringify(prompt);
|
||||
|
||||
@@ -121,7 +121,7 @@ export class AudibleService {
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
config: any = {},
|
||||
maxRetries: number = 3
|
||||
maxRetries: number = 5
|
||||
): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
|
||||
|
||||
export interface PlexRecentlyAddedPayload {
|
||||
jobId?: string;
|
||||
@@ -66,6 +67,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
|
||||
// Get library service (automatically selects Plex or Audiobookshelf)
|
||||
const libraryService = await getLibraryService();
|
||||
const thumbnailCacheService = getThumbnailCacheService();
|
||||
|
||||
try {
|
||||
// Get configured library ID
|
||||
@@ -73,6 +75,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
? await configService.get('audiobookshelf.library_id')
|
||||
: await configService.get('plex_audiobook_library_id');
|
||||
|
||||
// Get cover caching parameters (needed for thumbnail caching)
|
||||
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
|
||||
|
||||
// Fetch top 10 recently added items using abstraction layer
|
||||
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
|
||||
|
||||
@@ -93,7 +98,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.plexLibrary.create({
|
||||
const newLibraryItem = await prisma.plexLibrary.create({
|
||||
data: {
|
||||
plexGuid: item.externalId,
|
||||
plexRatingKey: item.id,
|
||||
@@ -111,6 +116,26 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
lastScannedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: newLibraryItem.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
newCount++;
|
||||
logger.info(`New item added: ${item.title} by ${item.author}`);
|
||||
} else {
|
||||
@@ -129,6 +154,26 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
lastScannedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: existing.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ScanPlexPayload } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
const libraryService = await getLibraryService();
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
const thumbnailCacheService = getThumbnailCacheService();
|
||||
|
||||
logger.info(`Backend mode: ${backendMode}`);
|
||||
|
||||
@@ -50,6 +52,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get cover caching parameters (needed for thumbnail caching)
|
||||
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
|
||||
|
||||
logger.info(`Fetching content from library ${targetLibraryId}`);
|
||||
|
||||
// 3. Get all audiobooks from library using abstraction layer
|
||||
@@ -97,6 +102,25 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: existing.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updatedCount++;
|
||||
} else {
|
||||
// Create new plex_library entry
|
||||
@@ -119,6 +143,25 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
});
|
||||
|
||||
// Cache library cover (synchronous with smart skip-if-exists logic)
|
||||
if (item.coverUrl && item.externalId) {
|
||||
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
||||
item.externalId,
|
||||
item.coverUrl,
|
||||
coverCachingParams.backendBaseUrl,
|
||||
coverCachingParams.authToken,
|
||||
coverCachingParams.backendMode
|
||||
);
|
||||
|
||||
// Update database with cached path if successful
|
||||
if (cachedPath) {
|
||||
await prisma.plexLibrary.update({
|
||||
where: { id: newLibraryItem.id },
|
||||
data: { cachedLibraryCoverPath: cachedPath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
newCount++;
|
||||
logger.info(`Added new: "${item.title}" by ${item.author}`);
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
triggerABSScan,
|
||||
} from '../audiobookshelf/api';
|
||||
import { ABSLibraryItem } from '../audiobookshelf/types';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
|
||||
export class AudiobookshelfLibraryService implements ILibraryService {
|
||||
private configService = getConfigService();
|
||||
|
||||
async testConnection(): Promise<LibraryConnectionResult> {
|
||||
try {
|
||||
@@ -87,6 +89,34 @@ export class AudiobookshelfLibraryService implements ILibraryService {
|
||||
await triggerABSScan(libraryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters needed for caching library covers
|
||||
* @returns Parameters for ThumbnailCacheService.cacheLibraryThumbnail()
|
||||
*/
|
||||
async getCoverCachingParams(): Promise<{
|
||||
backendBaseUrl: string;
|
||||
authToken: string;
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
}> {
|
||||
const config = await this.configService.getMany([
|
||||
'audiobookshelf.server_url',
|
||||
'audiobookshelf.api_token',
|
||||
]);
|
||||
|
||||
const serverUrl = config['audiobookshelf.server_url'];
|
||||
const authToken = config['audiobookshelf.api_token'];
|
||||
|
||||
if (!serverUrl || !authToken) {
|
||||
throw new Error('Audiobookshelf server configuration is incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
backendBaseUrl: serverUrl,
|
||||
authToken: authToken,
|
||||
backendMode: 'audiobookshelf',
|
||||
};
|
||||
}
|
||||
|
||||
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
|
||||
const metadata = item.media.metadata;
|
||||
return {
|
||||
|
||||
@@ -220,6 +220,28 @@ export class PlexLibraryService implements ILibraryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters needed for caching library covers
|
||||
* @returns Parameters for ThumbnailCacheService.cacheLibraryThumbnail()
|
||||
*/
|
||||
async getCoverCachingParams(): Promise<{
|
||||
backendBaseUrl: string;
|
||||
authToken: string;
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
}> {
|
||||
const config = await this.configService.getPlexConfig();
|
||||
|
||||
if (!config.serverUrl || !config.authToken) {
|
||||
throw new Error('Plex server configuration is incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
backendBaseUrl: config.serverUrl,
|
||||
authToken: config.authToken,
|
||||
backendMode: 'plex',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Plex audiobook to generic LibraryItem interface
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
const logger = RMABLogger.create('ThumbnailCache');
|
||||
|
||||
const CACHE_DIR = '/app/cache/thumbnails';
|
||||
const LIBRARY_CACHE_DIR = '/app/cache/library';
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB max per image
|
||||
const TIMEOUT_MS = 10000; // 10 second timeout for downloads
|
||||
|
||||
@@ -28,6 +29,18 @@ export class ThumbnailCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure library cache directory exists
|
||||
*/
|
||||
private async ensureLibraryCacheDir(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create library cache directory', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for a cached thumbnail
|
||||
* @param asin - Audible ASIN
|
||||
@@ -43,6 +56,28 @@ export class ThumbnailCacheService {
|
||||
return `${asin}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for a library cover using SHA-256 hash
|
||||
* @param plexGuid - Plex/ABS unique identifier (may contain special chars)
|
||||
* @param url - Original URL (used for extension)
|
||||
* @returns Filename for cached library cover
|
||||
*/
|
||||
private generateLibraryFilename(plexGuid: string, url: string): string {
|
||||
// Hash the plexGuid to handle special characters (://, ?, etc.)
|
||||
const hash = crypto.createHash('sha256').update(plexGuid).digest('hex').substring(0, 16);
|
||||
|
||||
// Extract file extension from URL (default to .jpg if not found)
|
||||
let ext = '.jpg';
|
||||
try {
|
||||
const urlPath = new URL(url).pathname;
|
||||
ext = path.extname(urlPath) || '.jpg';
|
||||
} catch {
|
||||
// If URL parsing fails, use default extension
|
||||
}
|
||||
|
||||
return `${hash}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache a thumbnail from a URL
|
||||
* @param asin - Audible ASIN
|
||||
@@ -98,6 +133,84 @@ export class ThumbnailCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and cache a library thumbnail from Plex/Audiobookshelf
|
||||
* @param plexGuid - Plex/ABS unique identifier
|
||||
* @param coverUrl - URL of the cover (full URL or relative path)
|
||||
* @param backendBaseUrl - Base URL of backend (Plex or ABS server)
|
||||
* @param authToken - Authentication token
|
||||
* @param backendMode - 'plex' or 'audiobookshelf'
|
||||
* @returns Local file path of cached thumbnail, or null if failed
|
||||
*/
|
||||
async cacheLibraryThumbnail(
|
||||
plexGuid: string,
|
||||
coverUrl: string,
|
||||
backendBaseUrl: string,
|
||||
authToken: string,
|
||||
backendMode: 'plex' | 'audiobookshelf'
|
||||
): Promise<string | null> {
|
||||
if (!coverUrl || !plexGuid || !backendBaseUrl || !authToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureLibraryCacheDir();
|
||||
|
||||
const filename = this.generateLibraryFilename(plexGuid, coverUrl);
|
||||
const filePath = path.join(LIBRARY_CACHE_DIR, filename);
|
||||
|
||||
// Check if file already exists (skip download for subsequent scans)
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
// File exists, return path immediately
|
||||
return filePath;
|
||||
} catch {
|
||||
// File doesn't exist, proceed with download
|
||||
}
|
||||
|
||||
// Construct full URL based on backend mode
|
||||
let fullUrl: string;
|
||||
if (backendMode === 'plex') {
|
||||
// Plex uses token in query string
|
||||
const separator = coverUrl.includes('?') ? '&' : '?';
|
||||
fullUrl = `${backendBaseUrl}${coverUrl}${separator}X-Plex-Token=${authToken}`;
|
||||
} else {
|
||||
// Audiobookshelf uses Authorization header
|
||||
fullUrl = coverUrl.startsWith('http') ? coverUrl : `${backendBaseUrl}${coverUrl}`;
|
||||
}
|
||||
|
||||
// Download image
|
||||
const response = await axios.get(fullUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: TIMEOUT_MS,
|
||||
maxContentLength: MAX_FILE_SIZE,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
...(backendMode === 'audiobookshelf' && { Authorization: `Bearer ${authToken}` }),
|
||||
},
|
||||
});
|
||||
|
||||
// Verify content type is an image
|
||||
const contentType = response.headers['content-type'];
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
logger.warn(`Invalid content type for library cover ${plexGuid}: ${contentType}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filePath, Buffer.from(response.data));
|
||||
|
||||
logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`);
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
// Log error but don't throw - graceful degradation
|
||||
logger.warn(`Failed to cache library thumbnail for ${plexGuid}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached thumbnail
|
||||
* @param asin - Audible ASIN
|
||||
@@ -150,6 +263,49 @@ export class ThumbnailCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up library thumbnails that are no longer referenced in the database
|
||||
* @param plexGuidToHashMap - Map of plexGuid to hash (for reverse lookup)
|
||||
* @returns Number of deleted files
|
||||
*/
|
||||
async cleanupLibraryThumbnails(plexGuidToHashMap: Map<string, string>): Promise<number> {
|
||||
try {
|
||||
await this.ensureLibraryCacheDir();
|
||||
|
||||
const files = await fs.readdir(LIBRARY_CACHE_DIR);
|
||||
let deletedCount = 0;
|
||||
|
||||
// Build reverse map: hash -> plexGuid
|
||||
const activeHashes = new Set<string>();
|
||||
for (const [plexGuid] of plexGuidToHashMap) {
|
||||
// Generate hash for each plexGuid (consistent with generateLibraryFilename)
|
||||
const hash = crypto.createHash('sha256').update(plexGuid).digest('hex').substring(0, 16);
|
||||
activeHashes.add(hash);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
// Extract hash from filename (remove extension)
|
||||
const hash = path.parse(file).name;
|
||||
|
||||
// If hash is not in active set, delete the file
|
||||
if (!activeHashes.has(hash)) {
|
||||
const filePath = path.join(LIBRARY_CACHE_DIR, file);
|
||||
await fs.unlink(filePath);
|
||||
deletedCount++;
|
||||
logger.info(`Deleted unused library thumbnail: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`);
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup library thumbnails', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached path for a thumbnail
|
||||
* @param cachedPath - Path from database
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Component: Library Cover Cache API Tests
|
||||
* Documentation: documentation/features/library-thumbnail-cache.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET } from '@/app/api/cache/library/[filename]/route';
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
describe('GET /api/cache/library/[filename]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns 400 for invalid filename with directory traversal', async () => {
|
||||
const request = new NextRequest('http://localhost/api/cache/library/../etc/passwd');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: '../etc/passwd' }) });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Invalid filename');
|
||||
});
|
||||
|
||||
it('returns 400 for filename with slashes', async () => {
|
||||
const request = new NextRequest('http://localhost/api/cache/library/path/to/file.jpg');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'path/to/file.jpg' }) });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Invalid filename');
|
||||
});
|
||||
|
||||
it('returns 404 when file does not exist', async () => {
|
||||
fsMock.access.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/missing.jpg');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'missing.jpg' }) });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('File not found');
|
||||
});
|
||||
|
||||
it('serves JPEG images with correct content type', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.readFile.mockResolvedValue(Buffer.from([0xFF, 0xD8, 0xFF]));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/a1b2c3d4e5f6g7h8.jpg');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'a1b2c3d4e5f6g7h8.jpg' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/jpeg');
|
||||
expect(response.headers.get('Cache-Control')).toBe('public, max-age=86400');
|
||||
});
|
||||
|
||||
it('serves PNG images with correct content type', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.readFile.mockResolvedValue(Buffer.from([0x89, 0x50, 0x4E, 0x47]));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/hash123456789abc.png');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'hash123456789abc.png' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png');
|
||||
});
|
||||
|
||||
it('serves WEBP images with correct content type', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.readFile.mockResolvedValue(Buffer.from('webp data'));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/cover.webp');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'cover.webp' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('returns 500 when file read fails', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.readFile.mockRejectedValue(new Error('Read error'));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/error.jpg');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'error.jpg' }) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBe('Internal server error');
|
||||
});
|
||||
|
||||
it('uses octet-stream for unknown file extensions', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.readFile.mockResolvedValue(Buffer.from('unknown data'));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/file.unknown');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'file.unknown' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('handles GIF images correctly', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.readFile.mockResolvedValue(Buffer.from('GIF89a'));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/cache/library/animated.gif');
|
||||
const response = await GET(request, { params: Promise.resolve({ filename: 'animated.gif' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Type')).toBe('image/gif');
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,16 @@ import { createPrismaMock } from '../helpers/prisma';
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({
|
||||
getRecentlyAdded: vi.fn(),
|
||||
getCoverCachingParams: vi.fn(),
|
||||
}));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const thumbnailCacheServiceMock = vi.hoisted(() => ({
|
||||
cacheLibraryThumbnail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -36,6 +40,10 @@ vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/thumbnail-cache.service', () => ({
|
||||
getThumbnailCacheService: () => thumbnailCacheServiceMock,
|
||||
}));
|
||||
|
||||
describe('processPlexRecentlyAddedCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -64,6 +72,15 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
plex_audiobook_library_id: 'lib-1',
|
||||
});
|
||||
configMock.get.mockResolvedValue('lib-1');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
backendMode: 'plex',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-1',
|
||||
@@ -111,6 +128,15 @@ describe('processPlexRecentlyAddedCheck', () => {
|
||||
'audiobookshelf.library_id': 'abs-lib',
|
||||
});
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
||||
{
|
||||
id: 'abs-1',
|
||||
|
||||
@@ -7,12 +7,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({ getLibraryItems: vi.fn() }));
|
||||
const libraryServiceMock = vi.hoisted(() => ({
|
||||
getLibraryItems: vi.fn(),
|
||||
getCoverCachingParams: vi.fn(),
|
||||
}));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const thumbnailCacheServiceMock = vi.hoisted(() => ({
|
||||
cacheLibraryThumbnail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: vi.fn(),
|
||||
@@ -34,6 +40,10 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/thumbnail-cache.service', () => ({
|
||||
getThumbnailCacheService: () => thumbnailCacheServiceMock,
|
||||
}));
|
||||
|
||||
describe('processScanPlex', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -48,6 +58,14 @@ describe('processScanPlex', () => {
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
backendMode: 'plex',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-1',
|
||||
@@ -118,6 +136,12 @@ describe('processScanPlex', () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
|
||||
await expect(processScanPlex({ jobId: 'job-2' })).rejects.toThrow(
|
||||
@@ -135,6 +159,14 @@ describe('processScanPlex', () => {
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
backendMode: 'plex',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-1',
|
||||
@@ -205,6 +237,14 @@ describe('processScanPlex', () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||
backendBaseUrl: 'http://abs',
|
||||
authToken: 'token',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
|
||||
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
||||
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([]);
|
||||
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
|
||||
@@ -16,8 +16,16 @@ const apiMock = vi.hoisted(() => ({
|
||||
triggerABSScan: vi.fn(),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => apiMock);
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('AudiobookshelfLibraryService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -147,4 +155,42 @@ describe('AudiobookshelfLibraryService', () => {
|
||||
|
||||
expect(apiMock.triggerABSScan).toHaveBeenCalledWith('lib-1');
|
||||
});
|
||||
|
||||
it('returns cover caching params for Audiobookshelf backend', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
'audiobookshelf.server_url': 'http://abs:13378',
|
||||
'audiobookshelf.api_token': 'abs-token-456',
|
||||
});
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const params = await service.getCoverCachingParams();
|
||||
|
||||
expect(params).toEqual({
|
||||
backendBaseUrl: 'http://abs:13378',
|
||||
authToken: 'abs-token-456',
|
||||
backendMode: 'audiobookshelf',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when getting cover caching params without server URL', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
'audiobookshelf.server_url': null,
|
||||
'audiobookshelf.api_token': 'token',
|
||||
});
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
|
||||
await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete');
|
||||
});
|
||||
|
||||
it('throws when getting cover caching params without API token', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
'audiobookshelf.server_url': 'http://abs',
|
||||
'audiobookshelf.api_token': null,
|
||||
});
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
|
||||
await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,4 +211,29 @@ describe('PlexLibraryService', () => {
|
||||
|
||||
await expect(service.triggerLibraryScan('lib-1')).rejects.toThrow('Plex server configuration is incomplete');
|
||||
});
|
||||
|
||||
it('returns cover caching params for Plex backend', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex:32400',
|
||||
authToken: 'plex-token-123',
|
||||
libraryId: 'lib-1',
|
||||
});
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const params = await service.getCoverCachingParams();
|
||||
|
||||
expect(params).toEqual({
|
||||
backendBaseUrl: 'http://plex:32400',
|
||||
authToken: 'plex-token-123',
|
||||
backendMode: 'plex',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when getting cover caching params without config', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
|
||||
await expect(service.getCoverCachingParams()).rejects.toThrow('Plex server configuration is incomplete');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,4 +122,200 @@ describe('ThumbnailCacheService', () => {
|
||||
|
||||
expect(service.getCacheDirectory()).toBe('/app/cache/thumbnails');
|
||||
});
|
||||
|
||||
describe('Library Thumbnail Caching', () => {
|
||||
it('returns null when missing required parameters', async () => {
|
||||
const service = new ThumbnailCacheService();
|
||||
|
||||
expect(await service.cacheLibraryThumbnail('', 'url', 'http://server', 'token', 'plex')).toBeNull();
|
||||
expect(await service.cacheLibraryThumbnail('guid', '', 'http://server', 'token', 'plex')).toBeNull();
|
||||
expect(await service.cacheLibraryThumbnail('guid', 'url', '', 'token', 'plex')).toBeNull();
|
||||
expect(await service.cacheLibraryThumbnail('guid', 'url', 'http://server', '', 'plex')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns cached path when library cover already exists', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheLibraryThumbnail(
|
||||
'plex://guid/123',
|
||||
'/library/metadata/456/thumb',
|
||||
'http://plex:32400',
|
||||
'token123',
|
||||
'plex'
|
||||
);
|
||||
|
||||
expect(result).toContain(path.join('app', 'cache', 'library'));
|
||||
expect(result).toMatch(/[a-f0-9]{16}\.jpg$/);
|
||||
expect(axiosMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads library cover for Plex backend with token in URL', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
axiosMock.get.mockResolvedValue({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
data: Buffer.from([1, 2, 3]),
|
||||
});
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheLibraryThumbnail(
|
||||
'plex://guid/789',
|
||||
'/library/metadata/123/thumb/456.jpg',
|
||||
'http://plex:32400',
|
||||
'plextoken',
|
||||
'plex'
|
||||
);
|
||||
|
||||
expect(result).toContain(path.join('app', 'cache', 'library'));
|
||||
expect(result).toMatch(/[a-f0-9]{16}\.jpg$/);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'http://plex:32400/library/metadata/123/thumb/456.jpg?X-Plex-Token=plextoken',
|
||||
expect.objectContaining({
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000,
|
||||
})
|
||||
);
|
||||
expect(fsMock.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads library cover for Audiobookshelf backend with auth header', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
axiosMock.get.mockResolvedValue({
|
||||
headers: { 'content-type': 'image/png' },
|
||||
data: Buffer.from([4, 5, 6]),
|
||||
});
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheLibraryThumbnail(
|
||||
'abs-item-456',
|
||||
'/api/items/abs-item-456/cover',
|
||||
'http://abs:13378',
|
||||
'abstoken',
|
||||
'audiobookshelf'
|
||||
);
|
||||
|
||||
// URL has no extension, so defaults to .jpg
|
||||
expect(result).toContain(path.join('app', 'cache', 'library'));
|
||||
expect(result).toMatch(/[a-f0-9]{16}\.jpg$/);
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'http://abs:13378/api/items/abs-item-456/cover',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer abstoken',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(fsMock.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-image content types for library covers', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
axiosMock.get.mockResolvedValue({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
data: Buffer.from('error page'),
|
||||
});
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheLibraryThumbnail(
|
||||
'guid',
|
||||
'/cover',
|
||||
'http://server',
|
||||
'token',
|
||||
'plex'
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fsMock.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates consistent SHA-256 hash filenames for same plexGuid', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result1 = await service.cacheLibraryThumbnail(
|
||||
'plex://guid/123',
|
||||
'/thumb.jpg',
|
||||
'http://server',
|
||||
'token',
|
||||
'plex'
|
||||
);
|
||||
const result2 = await service.cacheLibraryThumbnail(
|
||||
'plex://guid/123',
|
||||
'/thumb.jpg',
|
||||
'http://server',
|
||||
'token',
|
||||
'plex'
|
||||
);
|
||||
|
||||
expect(result1).toBe(result2);
|
||||
const filename = path.basename(result1 || '');
|
||||
expect(filename).toMatch(/^[a-f0-9]{16}\.jpg$/);
|
||||
});
|
||||
|
||||
it('cleans up orphaned library thumbnails', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
|
||||
// First, cache some files to get their actual hash filenames
|
||||
fsMock.access.mockRejectedValue(new Error('not found'));
|
||||
axiosMock.get.mockResolvedValue({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
data: Buffer.from([1, 2, 3]),
|
||||
});
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
const path1 = await service.cacheLibraryThumbnail('guid-1', '/cover.jpg', 'http://server', 'token', 'plex');
|
||||
const path2 = await service.cacheLibraryThumbnail('guid-2', '/cover.jpg', 'http://server', 'token', 'plex');
|
||||
|
||||
const filename1 = path.basename(path1 || '');
|
||||
const filename2 = path.basename(path2 || '');
|
||||
|
||||
// Now set up the cleanup test with actual filenames
|
||||
fsMock.readdir.mockResolvedValue([
|
||||
filename1, // Will be kept
|
||||
'orphaned123456ab.png', // Will be deleted
|
||||
filename2, // Will be kept
|
||||
]);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const plexGuidMap = new Map([
|
||||
['guid-1', 'any-value'],
|
||||
['guid-2', 'any-value'],
|
||||
]);
|
||||
|
||||
const deleted = await service.cleanupLibraryThumbnails(plexGuidMap);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(fsMock.unlink).toHaveBeenCalledTimes(1);
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining('orphaned123456ab.png')
|
||||
);
|
||||
});
|
||||
|
||||
it('handles errors gracefully when caching library thumbnails', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
axiosMock.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheLibraryThumbnail(
|
||||
'guid',
|
||||
'/cover',
|
||||
'http://server',
|
||||
'token',
|
||||
'plex'
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fsMock.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user