mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Merge branch 'ebook-piecewise'
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
**Critical:** This document defines AI-optimized documentation standards and development workflow. **NEVER PERFORM COMMITS ON THE REPOSITORY.**
|
||||
|
||||
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Efficient Documentation System
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,10 +38,16 @@
|
||||
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
||||
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||
|
||||
## E-book Sidecar
|
||||
- **Optional e-book downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
## E-book Support (First-Class)
|
||||
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Multi-source ebook downloads (Anna's Archive + Indexer Search)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Ebook indexer search (Prowlarr with ebook categories)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#flow-indexer-search)
|
||||
- **ASIN-based matching, format selection** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Non-blocking, atomic failures** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Ebook ranking algorithm (unified with audiobooks)** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md#ebook-torrent-ranking)
|
||||
- **Direct HTTP downloads from Anna's Archive** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
- **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior)
|
||||
- **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
- **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
||||
|
||||
## Automation Pipeline
|
||||
- **Full pipeline overview** → [phase3/README.md](phase3/README.md)
|
||||
@@ -108,8 +114,14 @@
|
||||
**"How do Usenet/NZB downloads work?"** → [phase3/sabnzbd.md](phase3/sabnzbd.md), [backend/services/jobs.md](backend/services/jobs.md)
|
||||
**"Can I use both qBittorrent and SABnzbd?"** → [phase3/download-clients.md](phase3/download-clients.md)
|
||||
**"How does Plex matching work?"** → [integrations/plex.md](integrations/plex.md)
|
||||
**"How does e-book sidecar work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md)
|
||||
**"How does e-book support work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
**"How do I enable e-book downloads?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md), [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
**"How do I configure ebook sources (Anna's Archive vs Indexer)?"** → [settings-pages.md](settings-pages.md#e-book-sidecar)
|
||||
**"How does ebook indexer search work?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#flow-indexer-search)
|
||||
**"How do I configure ebook categories per indexer?"** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed)
|
||||
**"How does ebook ranking work?"** → [phase3/ranking-algorithm.md](phase3/ranking-algorithm.md#ebook-torrent-ranking)
|
||||
**"What happens when I delete an ebook request?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior)
|
||||
**"Why do ebook requests have an orange badge?"** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#ui-representation)
|
||||
**"How do scheduled jobs work?"** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||
**"How do I configure external services?"** → [setup-wizard.md](setup-wizard.md), [settings-pages.md](settings-pages.md)
|
||||
**"What's the database schema?"** → [backend/database.md](backend/database.md)
|
||||
|
||||
@@ -18,10 +18,10 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
|
||||
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup)
|
||||
2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default
|
||||
3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default
|
||||
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), enabled by default
|
||||
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default
|
||||
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
|
||||
6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
|
||||
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (limit 100), triggers search jobs for matches, enabled by default
|
||||
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default
|
||||
|
||||
## Architecture: Bull + Cron
|
||||
|
||||
|
||||
@@ -1,307 +1,243 @@
|
||||
# E-book Sidecar
|
||||
# E-book Support
|
||||
|
||||
**Status:** ✅ Implemented | Optional e-book downloads from Anna's Archive
|
||||
**Status:** ✅ Implemented | First-class ebook requests with multi-source support (Anna's Archive + Indexer Search)
|
||||
|
||||
## Overview
|
||||
Automatically downloads e-books from Anna's Archive to accompany audiobooks, placing them in the same folder.
|
||||
Ebooks are first-class citizens in RMAB, with their own request type, tracking, and UI representation. When an audiobook request completes, an ebook request is automatically created (if a source is enabled). Supports multiple sources: Anna's Archive (direct HTTP) and Indexer Search (via Prowlarr with ebook categories).
|
||||
|
||||
## Key Details
|
||||
- **When:** Runs during file organization (after audiobook copied, after cover art)
|
||||
- **Matching:** ASIN-based search (exact match)
|
||||
- **Non-blocking:** Failures don't affect audiobook download
|
||||
- **Atomic:** Either succeeds or fails gracefully
|
||||
- **Location:** E-book placed in same directory as audiobook
|
||||
- **Filename:** `[Title] - [Author].[format]` (sanitized)
|
||||
|
||||
## Configuration
|
||||
### First-Class Ebook Requests
|
||||
- **Request Type:** `type: 'ebook'` (vs `'audiobook'`)
|
||||
- **Parent Relationship:** Ebook requests are children of audiobook requests (`parentRequestId`)
|
||||
- **Terminal State:** `downloaded` (ebooks don't have "available" state like audiobooks)
|
||||
- **UI Badge:** Orange (#f16f19) ebook badge to distinguish from audiobooks
|
||||
- **Separate Tracking:** Own progress, status, and error handling
|
||||
|
||||
**Admin Settings → E-book Sidecar tab**
|
||||
### Source Priority
|
||||
1. **Anna's Archive** (if enabled) - Direct HTTP downloads
|
||||
- Searched first via ASIN, then title + author
|
||||
- Uses FlareSolverr if configured (Cloudflare bypass)
|
||||
2. **Indexer Search** (if enabled, and no Anna's Archive result)
|
||||
- Searches Prowlarr with ebook categories (default: 7020)
|
||||
- Ranks using unified ranking algorithm with ebook-specific scoring
|
||||
- Downloads via qBittorrent (torrents) or SABnzbd (Usenet)
|
||||
3. **Both disabled** → Ebook downloads disabled entirely
|
||||
|
||||
### Flow (Anna's Archive)
|
||||
1. Audiobook organization completes
|
||||
2. Ebook request created automatically (if source enabled)
|
||||
3. `search_ebook` job searches Anna's Archive
|
||||
4. `start_direct_download` downloads via HTTP
|
||||
5. `organize_files` copies to audiobook folder
|
||||
6. Request marked as `downloaded` (terminal)
|
||||
7. "Available" notification sent
|
||||
|
||||
### Flow (Indexer Search)
|
||||
1. Audiobook organization completes
|
||||
2. Ebook request created automatically (if source enabled)
|
||||
3. `search_ebook` job searches indexers (if Anna's Archive failed/disabled)
|
||||
4. `download_torrent` job adds to qBittorrent/SABnzbd (reuses audiobook processor)
|
||||
5. `monitor_download` tracks progress
|
||||
6. `organize_files` copies to audiobook folder
|
||||
7. Request marked as `downloaded` (terminal)
|
||||
8. Torrent left to seed (respects seeding limits)
|
||||
|
||||
### Configuration
|
||||
|
||||
**Admin Settings → E-book Sidecar tab** (3 sections)
|
||||
|
||||
#### Section 1: Anna's Archive
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
||||
|
||||
#### Section 2: Indexer Search
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr |
|
||||
|
||||
*Note: Ebook categories are configured per-indexer in Settings → Indexers → Edit Indexer → EBook tab*
|
||||
|
||||
#### Section 3: General Settings
|
||||
| Key | Default | Options | Description |
|
||||
|-----|---------|---------|-------------|
|
||||
| `ebook_sidecar_enabled` | `false` | `true/false` | Enable feature |
|
||||
| `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | URL | Base URL (mirror resilience) |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | URL | FlareSolverr proxy URL (optional) |
|
||||
| `ebook_auto_grab_enabled` | `true` | `true, false` | Auto-create ebook requests after audiobook downloads |
|
||||
|
||||
**Stored in:** `Configuration` table (database)
|
||||
*Note: Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.*
|
||||
|
||||
## Database Schema
|
||||
|
||||
**Request model additions:**
|
||||
```prisma
|
||||
type String @default("audiobook") // 'audiobook' | 'ebook'
|
||||
parentRequestId String? @map("parent_request_id")
|
||||
parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id])
|
||||
childRequests Request[] @relation("EbookParent")
|
||||
```
|
||||
|
||||
**Indexes:** `type`, `parentRequestId`
|
||||
|
||||
## Job Processors
|
||||
|
||||
### search_ebook
|
||||
- Searches Anna's Archive first (if enabled), then indexers (if enabled)
|
||||
- Anna's Archive: Creates download history with `downloadClient: 'direct'`, triggers `start_direct_download`
|
||||
- Indexer: Triggers `download_torrent` job (reuses audiobook processor)
|
||||
|
||||
### start_direct_download
|
||||
- Downloads file via HTTP with progress tracking
|
||||
- Tries multiple slow download links on failure
|
||||
- Triggers `organize_files` on success
|
||||
|
||||
### download_torrent (shared with audiobooks)
|
||||
- Routes to qBittorrent (torrents) or SABnzbd (Usenet)
|
||||
- Creates download history with indexer metadata
|
||||
- Triggers `monitor_download` job
|
||||
|
||||
## Ranking Algorithm (Indexer Results)
|
||||
|
||||
Ebook torrent ranking uses unified algorithm with ebook-specific scoring:
|
||||
|
||||
| Component | Points | Description |
|
||||
|-----------|--------|-------------|
|
||||
| **Title/Author Match** | 60 pts | Reuses audiobook matching logic (word coverage, author presence) |
|
||||
| **Format Match** | 10 pts | 10 pts if matches preferred format, 0 otherwise |
|
||||
| **Size Quality** | 15 pts | Inverted: < 5MB = 15pts, 5-15MB = 10pts, 15-20MB = 5pts |
|
||||
| **Seeder Count** | 15 pts | Logarithmic scaling (same as audiobooks) |
|
||||
|
||||
**Filtering:**
|
||||
- Files > 20 MB are filtered out (too large for ebooks)
|
||||
- Dual threshold: base score >= 50 AND final score >= 50
|
||||
|
||||
**Bonus System:** Same as audiobooks (indexer priority, flag bonuses)
|
||||
|
||||
## Delete Behavior
|
||||
|
||||
**Ebook deletion is different from audiobook deletion:**
|
||||
- Only deletes ebook files (`.epub`, `.pdf`, `.mobi`, etc.)
|
||||
- Does NOT delete the title folder (audiobook files remain)
|
||||
- Does NOT delete from backend library (Plex/ABS)
|
||||
- Does NOT clear audiobook availability linkage
|
||||
- Soft-deletes the ebook request record
|
||||
- Torrents left to seed (respects seeding limits)
|
||||
|
||||
## UI Representation
|
||||
|
||||
### RequestCard
|
||||
- Orange ebook badge displayed next to status badge
|
||||
- Orange book icon for placeholder cover art
|
||||
- Interactive search disabled (Anna's Archive only)
|
||||
|
||||
### Status Flow
|
||||
```
|
||||
pending → searching → downloading → processing → downloaded (terminal)
|
||||
↘ awaiting_search (retry) ↗
|
||||
```
|
||||
|
||||
## FlareSolverr Integration
|
||||
|
||||
Anna's Archive uses Cloudflare protection which may block direct scraping requests. FlareSolverr solves this by using a headless browser to bypass the protection.
|
||||
|
||||
### What is FlareSolverr?
|
||||
- Proxy server using headless Chrome/Chromium
|
||||
- Automatically solves Cloudflare challenges
|
||||
- Returns HTML content after challenge is solved
|
||||
- Open source: https://github.com/FlareSolverr/FlareSolverr
|
||||
|
||||
### When to Use FlareSolverr
|
||||
- **Required:** When e-book downloads consistently fail with no search results
|
||||
- **Optional:** If direct requests work (depends on Cloudflare's current state)
|
||||
- **Recommended:** For reliable, consistent downloads
|
||||
Anna's Archive uses Cloudflare protection. FlareSolverr bypasses this using a headless browser.
|
||||
|
||||
### Setup
|
||||
1. Run FlareSolverr via Docker:
|
||||
```bash
|
||||
docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
||||
```
|
||||
2. In Admin Settings → E-book Sidecar, enter: `http://localhost:8191`
|
||||
3. Click "Test Connection" to verify
|
||||
```bash
|
||||
docker run -d --name flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
||||
```
|
||||
|
||||
### How It Works
|
||||
1. Requests are routed through FlareSolverr
|
||||
2. FlareSolverr loads the page in headless Chrome
|
||||
3. If Cloudflare challenge appears, it waits for solution
|
||||
4. HTML is returned after page loads
|
||||
5. Falls back to direct requests if FlareSolverr fails
|
||||
Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
|
||||
|
||||
### Performance Impact
|
||||
- **First request:** ~5-10 seconds (browser startup)
|
||||
- **Subsequent requests:** ~2-5 seconds per page
|
||||
- **Total time:** ~15-30 seconds per e-book (vs ~5-15 without)
|
||||
### Performance
|
||||
- First request: ~5-10 seconds
|
||||
- Subsequent: ~2-5 seconds per page
|
||||
- Total: ~15-30 seconds per ebook
|
||||
|
||||
## How It Works
|
||||
## Scraping Strategy (Anna's Archive)
|
||||
|
||||
### Flow
|
||||
1. **Trigger:** File organization completes audiobook copy
|
||||
2. **Check:** `ebook_sidecar_enabled === 'true'`
|
||||
3. **Search:** Try ASIN first (if available), then fall back to title + author
|
||||
4. **Extract MD5:** First search result → MD5 hash
|
||||
5. **Get Download Links:** Find "no waitlist" slow download links
|
||||
6. **Extract URL:** Parse slow download page for actual file server URL
|
||||
7. **Download:** Stream file to audiobook directory
|
||||
8. **Rename:** Sanitize filename based on metadata
|
||||
|
||||
### Scraping Strategy
|
||||
|
||||
**Method 1: ASIN Search (exact match)**
|
||||
### Method 1: ASIN Search (exact match)
|
||||
```
|
||||
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
↓
|
||||
MD5 Page: https://annas-archive.li/md5/[md5]
|
||||
↓ (Filter: "slow partner server" links)
|
||||
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
|
||||
↓ (Parse for actual download URL)
|
||||
File Server: http://[server-ip]:port/path/to/file.epub
|
||||
```
|
||||
|
||||
**Method 2: Title + Author Search (fallback)**
|
||||
```
|
||||
Search: https://annas-archive.li/search?q=The+Housemaid+Freida+McFadden
|
||||
&ext=epub
|
||||
&content=book_nonfiction&content=book_fiction&content=book_unknown
|
||||
&lang=en
|
||||
↓
|
||||
(Same flow as ASIN search from MD5 page onwards)
|
||||
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
|
||||
↓
|
||||
File Server: http://[server]/path/to/file.epub
|
||||
```
|
||||
|
||||
### Matching Priority
|
||||
1. **ASIN** (exact match - most accurate, if available)
|
||||
2. **Title + Author** (fuzzy match with book/language filters)
|
||||
|
||||
### Retry Logic
|
||||
- **Max attempts:** 5 slow download links
|
||||
- **Timeout:** 60 seconds per download
|
||||
- **Delays:** 1.5 seconds between requests
|
||||
- **Retries:** 3x for 5xx errors with exponential backoff
|
||||
|
||||
## Format Support
|
||||
|
||||
| Format | Extension | Recommended | Notes |
|
||||
|--------|-----------|-------------|-------|
|
||||
| EPUB | `.epub` | ✅ Yes | Most compatible with e-readers |
|
||||
| PDF | `.pdf` | ⚠️ Sometimes | Best for fixed-layout books |
|
||||
| MOBI | `.mobi` | ⚠️ Legacy | Kindle (older devices) |
|
||||
| AZW3 | `.azw3` | ⚠️ Sometimes | Kindle (newer devices) |
|
||||
| Any | `[first available]` | ❌ No | Downloads first match |
|
||||
|
||||
**Recommendation:** Use EPUB for maximum compatibility.
|
||||
### Method 2: Title + Author (fallback)
|
||||
```
|
||||
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
|
||||
↓ (Same flow from MD5 page)
|
||||
```
|
||||
|
||||
## File Naming
|
||||
|
||||
**Pattern:** `[Title] - [Author].[format]`
|
||||
|
||||
**Sanitization:**
|
||||
- Remove invalid chars: `<>:"/\|?*`
|
||||
- Collapse multiple spaces
|
||||
- Trim leading/trailing spaces and dots
|
||||
- Limit to 100 characters
|
||||
|
||||
**Examples:**
|
||||
- `The Housemaid - Freida McFadden.epub`
|
||||
- `Project Hail Mary - Andy Weir.pdf`
|
||||
- Remove: `<>:"/\|?*`
|
||||
- Collapse spaces, trim, limit to 200 chars
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Graceful Failures (non-blocking):**
|
||||
- No ASIN available → Skip silently (log info)
|
||||
- No search results → Log warning, continue audiobook
|
||||
- No download links → Log warning, continue audiobook
|
||||
- All downloads fail → Log error, continue audiobook
|
||||
- Download timeout → Log error, continue audiobook
|
||||
**Non-blocking errors:**
|
||||
- No search results → Request goes to `awaiting_search` for retry
|
||||
- All downloads fail → Same retry behavior
|
||||
- Audiobook organization never affected
|
||||
|
||||
**Never Blocks Audiobook:**
|
||||
- All e-book errors are non-fatal
|
||||
- Audiobook organization completes regardless
|
||||
- Errors logged to job events (visible in admin)
|
||||
## Technical Files
|
||||
|
||||
## Logging
|
||||
**Processors:**
|
||||
- `src/lib/processors/search-ebook.processor.ts` - Multi-source search
|
||||
- `src/lib/processors/direct-download.processor.ts` - Anna's Archive downloads
|
||||
- `src/lib/processors/download-torrent.processor.ts` - Indexer downloads (shared)
|
||||
- `src/lib/processors/organize-files.processor.ts` (ebook branch)
|
||||
|
||||
**Success (with FlareSolverr):**
|
||||
```
|
||||
E-book sidecar enabled, searching for e-book...
|
||||
Using FlareSolverr at http://localhost:8191
|
||||
Searching by ASIN: B09TWSRMCB (format: epub)...
|
||||
Found via ASIN: 3b6f9c0f1665c4ba6e3214d43c37e1de
|
||||
Found MD5: 3b6f9c0f1665c4ba6e3214d43c37e1de
|
||||
Found 8 download link(s)
|
||||
Attempting download link 1/5...
|
||||
Downloading from: 93.123.118.12
|
||||
E-book downloaded: The Housemaid - Freida McFadden.epub
|
||||
```
|
||||
**Services:**
|
||||
- `src/lib/services/ebook-scraper.ts` - Anna's Archive scraping
|
||||
- `src/lib/services/job-queue.service.ts` (ebook job types)
|
||||
|
||||
**Success (ASIN match, direct):**
|
||||
```
|
||||
E-book sidecar enabled, searching for e-book...
|
||||
Searching by ASIN: B09TWSRMCB (format: epub)...
|
||||
Found via ASIN: 3b6f9c0f1665c4ba6e3214d43c37e1de
|
||||
Found MD5: 3b6f9c0f1665c4ba6e3214d43c37e1de
|
||||
Found 8 download link(s)
|
||||
Attempting download link 1/5...
|
||||
Downloading from: 93.123.118.12
|
||||
E-book downloaded: The Housemaid - Freida McFadden.epub
|
||||
```
|
||||
**Utils:**
|
||||
- `src/lib/utils/file-organizer.ts` (`organizeEbook` method)
|
||||
- `src/lib/utils/ranking-algorithm.ts` (`rankEbookTorrents` function)
|
||||
- `src/lib/utils/indexer-grouping.ts` (supports `'ebook'` type)
|
||||
|
||||
**Success (Title fallback):**
|
||||
```
|
||||
E-book sidecar enabled, searching for e-book...
|
||||
Searching by ASIN: B09TWSRMCB (format: epub)...
|
||||
No results for ASIN, falling back to title + author search...
|
||||
Searching by title + author: "The Housemaid" by Freida McFadden...
|
||||
Found via title search: 3b6f9c0f1665c4ba6e3214d43c37e1de
|
||||
Found MD5: 3b6f9c0f1665c4ba6e3214d43c37e1de
|
||||
Found 8 download link(s)
|
||||
E-book downloaded: The Housemaid - Freida McFadden.epub
|
||||
```
|
||||
**UI:**
|
||||
- `src/components/requests/RequestCard.tsx` (ebook badge)
|
||||
|
||||
**Failure:**
|
||||
```
|
||||
E-book sidecar enabled, searching for e-book...
|
||||
Searching by ASIN: B09TWSRMCB (format: epub)...
|
||||
No results for ASIN, falling back to title + author search...
|
||||
Searching by title + author: "The Housemaid" by Freida McFadden...
|
||||
No search results found for title: "The Housemaid" by Freida McFadden
|
||||
E-book download failed: No search results found (tried ASIN and title+author)
|
||||
```
|
||||
**Delete:**
|
||||
- `src/lib/services/request-delete.service.ts` (ebook-specific logic)
|
||||
|
||||
## Troubleshooting
|
||||
## Format Support
|
||||
|
||||
### E-book Not Downloaded
|
||||
| Format | Extension | Recommended |
|
||||
|--------|-----------|-------------|
|
||||
| EPUB | `.epub` | Yes |
|
||||
| PDF | `.pdf` | Sometimes |
|
||||
| MOBI | `.mobi` | Legacy |
|
||||
| AZW3 | `.azw3` | Sometimes |
|
||||
|
||||
**Cause:** No matching e-book in Anna's Archive (tried ASIN and title+author)
|
||||
**Solution:** Not all audiobooks have e-book equivalents, this is expected
|
||||
## Indexer Categories
|
||||
|
||||
**Cause:** ASIN mismatch (Anna's Archive has different ASIN)
|
||||
**Solution:** Feature now automatically falls back to title + author search
|
||||
Indexer configuration supports separate category arrays for audiobooks and ebooks:
|
||||
- **Audiobook Categories:** Default `[3030]` (Audio/Audiobook)
|
||||
- **Ebook Categories:** Default `[7020]` (Books/EBook)
|
||||
|
||||
**Cause:** All download links failed
|
||||
**Solution:** Check job logs for errors, may be temporary server issues
|
||||
|
||||
### Wrong Format Downloaded
|
||||
|
||||
**Cause:** Preferred format not available
|
||||
**Solution:** Anna's Archive doesn't have that format, falls back to available format
|
||||
|
||||
### Download Timeout
|
||||
|
||||
**Cause:** Slow file server or large file
|
||||
**Solution:** Automatic retry with next download link
|
||||
|
||||
### Feature Not Working
|
||||
|
||||
**Cause:** Feature disabled
|
||||
**Solution:** Admin Settings → E-book Sidecar → Enable toggle
|
||||
|
||||
### Cloudflare Blocking
|
||||
|
||||
**Cause:** Anna's Archive has Cloudflare protection enabled
|
||||
**Solution:** Configure FlareSolverr (see FlareSolverr Integration section)
|
||||
|
||||
**Symptoms:**
|
||||
- No search results found
|
||||
- Requests timing out
|
||||
- Errors about Cloudflare challenge
|
||||
|
||||
### FlareSolverr Not Working
|
||||
|
||||
**Cause:** FlareSolverr not running or unreachable
|
||||
**Solution:**
|
||||
1. Verify FlareSolverr is running: `docker ps | grep flaresolverr`
|
||||
2. Check URL is correct (usually `http://localhost:8191`)
|
||||
3. Test connection in Admin Settings
|
||||
|
||||
**Cause:** FlareSolverr timing out
|
||||
**Solution:** FlareSolverr may need more time; check container logs for errors
|
||||
|
||||
## Security & Legal
|
||||
|
||||
**Important Notes:**
|
||||
- Anna's Archive is a shadow library
|
||||
- Use at your own discretion and responsibility
|
||||
- Ensure compliance with local laws and regulations
|
||||
- Feature is optional and disabled by default
|
||||
- No API key required (web scraping)
|
||||
|
||||
**Privacy:**
|
||||
- User-Agent: `ReadMeABook/1.0 (Audiobook Automation)`
|
||||
- No tracking or analytics
|
||||
- Distributed (each user scrapes for themselves)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
**Files:**
|
||||
- Service: `src/lib/services/ebook-scraper.ts`
|
||||
- Integration: `src/lib/utils/file-organizer.ts` (line 265+)
|
||||
- Settings API: `src/app/api/admin/settings/ebook/route.ts`
|
||||
- FlareSolverr Test API: `src/app/api/admin/settings/ebook/test-flaresolverr/route.ts`
|
||||
- UI: `src/app/admin/settings/page.tsx` (ebook tab)
|
||||
|
||||
**Dependencies:**
|
||||
- axios (HTTP requests)
|
||||
- cheerio (HTML parsing)
|
||||
- fs/promises (file operations)
|
||||
|
||||
**Caching:**
|
||||
- MD5 lookups cached in-memory (prevents re-scraping same ASIN)
|
||||
- Cache cleared on service restart
|
||||
|
||||
## Performance
|
||||
|
||||
**Impact:**
|
||||
- **Network:** 3-5 requests per e-book (search, MD5, slow download pages)
|
||||
- **Time:** ~5-15 seconds per e-book (depends on file server)
|
||||
- **Storage:** E-books typically 1-50 MB
|
||||
- **CPU:** Minimal (streaming download)
|
||||
Categories are configured per-indexer via the tabbed interface in the Edit Indexer modal.
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Match Accuracy:** Title + author search may return wrong book if title is common
|
||||
2. **Format Availability:** Depends on Anna's Archive catalog
|
||||
3. **Download Speed:** Depends on file server load
|
||||
4. **Language:** Title search filters for English books only
|
||||
5. **Success Rate:** ~70-90% (ASIN has higher accuracy, title fallback is less precise)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- ISBN-13 fallback matching (between ASIN and title search)
|
||||
- Format preference priority list (try EPUB, then PDF, then MOBI)
|
||||
- Per-request override (API endpoint)
|
||||
- Statistics tracking (success rate, formats, match method)
|
||||
- Rate limit monitoring
|
||||
- Relevance scoring for title search results
|
||||
1. Title search may return wrong book for common titles
|
||||
2. Download speed depends on file server load (Anna's Archive)
|
||||
3. English books only (title search filter for Anna's Archive)
|
||||
4. Format detection from torrent titles may be imprecise
|
||||
|
||||
## Related
|
||||
- [File Organization](../phase3/file-organization.md) - Where e-book download happens
|
||||
- [File Organization](../phase3/file-organization.md) - Ebook organization
|
||||
- [Settings Pages](../settings-pages.md) - Configuration UI
|
||||
- [Configuration Service](../backend/services/config.md) - Settings storage
|
||||
- [Ranking Algorithm](../phase3/ranking-algorithm.md) - Ebook ranking
|
||||
- [Request Deletion](../admin-features/request-deletion.md) - Delete behavior
|
||||
- [Prowlarr Integration](../phase3/prowlarr.md) - Indexer search
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Intelligent Ranking Algorithm
|
||||
|
||||
**Status:** ✅ Implemented | Comprehensive edge case test coverage
|
||||
**Tests:** tests/utils/ranking-algorithm.test.ts (73 test cases)
|
||||
**Tests:** tests/utils/ranking-algorithm.test.ts (80+ test cases)
|
||||
|
||||
Evaluates and scores torrents to automatically select best audiobook download.
|
||||
|
||||
@@ -19,6 +19,7 @@ Evaluates and scores torrents to automatically select best audiobook download.
|
||||
- ✅ **Author presence check (10 tests)**
|
||||
- ✅ **Context-aware filtering (3 tests)**
|
||||
- ✅ **API compatibility (2 tests)**
|
||||
- ✅ **CamelCase and punctuation separator handling (7 tests)**
|
||||
|
||||
**Tested edge cases prevent regressions from previous tweaks:**
|
||||
- "We Are Legion (We Are Bob)" matching with/without subtitle
|
||||
@@ -35,6 +36,18 @@ Evaluates and scores torrents to automatically select best audiobook download.
|
||||
|
||||
**1. Title/Author Match (60 pts max) - MOST IMPORTANT**
|
||||
|
||||
**Pre-Processing: Text Normalization**
|
||||
- All titles and author names are normalized before matching
|
||||
- **CamelCase splitting:** `"TheCorrespondent"` → `"the correspondent"`
|
||||
- **Punctuation to spaces:** `"Twelve.Months-Jim"` → `"twelve months jim"`
|
||||
- **Preserves apostrophes:** `"O'Brien"` remains `"o'brien"`
|
||||
- Handles common indexer naming patterns (NZB, torrent scene releases)
|
||||
|
||||
**Examples of normalization:**
|
||||
- `"VirginaEvans TheCorrespondent"` → `"virgina evans the correspondent"`
|
||||
- `"Twelve.Months-Jim.Butcher"` → `"twelve months jim butcher"`
|
||||
- `"Author_Name-Book.Title.2024"` → `"author name book title 2024"`
|
||||
|
||||
**Multi-Stage Matching:**
|
||||
|
||||
**Stage 1: Word Coverage Filter (MANDATORY)**
|
||||
@@ -286,6 +299,80 @@ const ranked = rankTorrents(torrents, audiobook, {
|
||||
return ranked; // User can see torrents without author info
|
||||
```
|
||||
|
||||
## Ebook Torrent Ranking
|
||||
|
||||
The ranking algorithm also supports ebook torrents from indexers with ebook-specific scoring.
|
||||
|
||||
### Unified Code Architecture
|
||||
|
||||
Ebook ranking **reuses** the following from audiobook ranking:
|
||||
- `scoreMatch()` - Title/author matching (60 pts)
|
||||
- `scoreSeeders()` - Seeder count scoring (15 pts)
|
||||
- Bonus modifier system (indexer priority, flag bonuses)
|
||||
- Dual threshold filtering (base >= 50, final >= 50)
|
||||
|
||||
### Ebook-Specific Scoring
|
||||
|
||||
**Format Match (10 pts max)**
|
||||
- 10 pts if torrent format matches preferred format
|
||||
- 0 pts otherwise (no partial credit)
|
||||
- Format detected from torrent title keywords: `.epub`, `.pdf`, `.mobi`, `.azw3`, etc.
|
||||
|
||||
**Size Quality (15 pts max, INVERTED)**
|
||||
- < 5 MB: 15 pts (optimal for ebooks)
|
||||
- 5-15 MB: 10 pts (may have images)
|
||||
- 15-20 MB: 5 pts (large but acceptable)
|
||||
- > 20 MB: **Filtered out** (too large for ebooks)
|
||||
|
||||
### Ebook vs Audiobook Comparison
|
||||
|
||||
| Component | Audiobook | Ebook |
|
||||
|-----------|-----------|-------|
|
||||
| Title/Author | 60 pts (reused) | 60 pts (reused) |
|
||||
| Format | 10 pts (M4B > M4A > MP3) | 10 pts (match = 10, else 0) |
|
||||
| Size | 15 pts (larger = better) | 15 pts (smaller = better) |
|
||||
| Seeders | 15 pts (reused) | 15 pts (reused) |
|
||||
| Size Filter | < 20 MB filtered | > 20 MB filtered |
|
||||
|
||||
### Ebook Interface
|
||||
|
||||
```typescript
|
||||
interface EbookTorrentRequest {
|
||||
title: string;
|
||||
author: string;
|
||||
preferredFormat: string; // 'epub', 'pdf', 'mobi', etc.
|
||||
}
|
||||
|
||||
interface RankEbookTorrentsOptions {
|
||||
indexerPriorities?: Map<number, number>;
|
||||
flagConfigs?: IndexerFlagConfig[];
|
||||
requireAuthor?: boolean; // Default: true
|
||||
}
|
||||
|
||||
function rankEbookTorrents(
|
||||
torrents: TorrentResult[],
|
||||
ebook: EbookTorrentRequest,
|
||||
options?: RankEbookTorrentsOptions
|
||||
): RankedEbookTorrent[];
|
||||
```
|
||||
|
||||
### Ebook Usage Example
|
||||
|
||||
```typescript
|
||||
// Ebook search from indexers
|
||||
const ranked = rankEbookTorrents(prowlarrResults, {
|
||||
title: 'Project Hail Mary',
|
||||
author: 'Andy Weir',
|
||||
preferredFormat: 'epub',
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: true,
|
||||
});
|
||||
|
||||
const bestEbook = ranked[0]; // Safe to auto-download
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- string-similarity (fuzzy matching)
|
||||
|
||||
@@ -66,11 +66,66 @@ src/app/admin/settings/
|
||||
|
||||
1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle
|
||||
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
|
||||
4. **Download Client** - Type, URL, credentials (masked)
|
||||
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
|
||||
6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
||||
7. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
|
||||
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
|
||||
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
||||
8. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
|
||||
|
||||
## E-book Sidecar
|
||||
|
||||
**Purpose:** Configure ebook download sources and preferences to accompany audiobook downloads.
|
||||
|
||||
**Tab Structure (3 sections):**
|
||||
|
||||
1. **Anna's Archive Section**
|
||||
- Enable toggle for Anna's Archive downloads
|
||||
- Base URL (default: `https://annas-archive.li`)
|
||||
- FlareSolverr URL (optional, for Cloudflare bypass)
|
||||
|
||||
2. **Indexer Search Section**
|
||||
- Enable toggle for indexer-based ebook search via Prowlarr
|
||||
- Hint directing users to Indexers tab for category configuration
|
||||
|
||||
3. **General Settings Section** (visible when any source enabled)
|
||||
- Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any
|
||||
- Auto-grab toggle: Automatically create ebook requests after audiobook downloads
|
||||
|
||||
**Configuration Keys:**
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive |
|
||||
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr |
|
||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
||||
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||
|
||||
**Behavior:**
|
||||
- If Anna's Archive enabled → Searches Anna's Archive first
|
||||
- If Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled
|
||||
- If both disabled → Ebook downloads completely off
|
||||
- If auto-grab disabled → Manual "Fetch Ebook" button only (admin buttons still work)
|
||||
|
||||
## Indexer Categories (Tabbed)
|
||||
|
||||
**Purpose:** Configure separate category sets for audiobook and ebook searches per indexer.
|
||||
|
||||
**UI:** Edit Indexer modal has Categories section with two tabs:
|
||||
- **AudioBook tab** - Categories for audiobook searches (default: `[3030]`)
|
||||
- **EBook tab** - Categories for ebook searches (default: `[7020]`)
|
||||
|
||||
**Storage:** `prowlarr_indexers` JSON config stores:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "MyIndexer",
|
||||
"audiobookCategories": [3030],
|
||||
"ebookCategories": [7020],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Audible Region
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "requests" ADD COLUMN IF NOT EXISTS "type" TEXT NOT NULL DEFAULT 'audiobook';
|
||||
ALTER TABLE "requests" ADD COLUMN IF NOT EXISTS "parent_request_id" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "requests_type_idx" ON "requests"("type");
|
||||
CREATE INDEX IF NOT EXISTS "requests_parent_request_id_idx" ON "requests"("parent_request_id");
|
||||
|
||||
-- AddForeignKey (with ON DELETE SET NULL)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'requests_parent_request_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "requests" ADD CONSTRAINT "requests_parent_request_id_fkey"
|
||||
FOREIGN KEY ("parent_request_id") REFERENCES "requests"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
+13
-2
@@ -212,7 +212,8 @@ model Request {
|
||||
audiobookId String @map("audiobook_id")
|
||||
status String @default("pending")
|
||||
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn
|
||||
// Flow: pending → searching → downloading → processing → downloaded → available (when matched in Plex)
|
||||
// Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
|
||||
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
|
||||
progress Int @default(0) // 0-100
|
||||
priority Int @default(0)
|
||||
errorMessage String? @map("error_message") @db.Text
|
||||
@@ -227,6 +228,11 @@ model Request {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
// Request type: 'audiobook' (default) or 'ebook'
|
||||
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
|
||||
type String @default("audiobook") // 'audiobook' | 'ebook'
|
||||
parentRequestId String? @map("parent_request_id") // Links ebook request to originating audiobook request
|
||||
|
||||
// Soft delete support
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedBy String? @map("deleted_by") // Admin user ID
|
||||
@@ -236,12 +242,16 @@ model Request {
|
||||
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
|
||||
downloadHistory DownloadHistory[]
|
||||
jobs Job[]
|
||||
parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull)
|
||||
childRequests Request[] @relation("EbookParent")
|
||||
|
||||
@@index([userId])
|
||||
@@index([audiobookId])
|
||||
@@index([status])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@index([deletedAt])
|
||||
@@index([type])
|
||||
@@index([parentRequestId])
|
||||
@@map("requests")
|
||||
}
|
||||
|
||||
@@ -260,7 +270,7 @@ model DownloadHistory {
|
||||
leechers Int?
|
||||
qualityScore Int? @map("quality_score")
|
||||
selected Boolean @default(false)
|
||||
downloadClient String? @map("download_client") // qbittorrent, sabnzbd
|
||||
downloadClient String? @map("download_client") // qbittorrent, sabnzbd, direct (HTTP download for ebooks)
|
||||
downloadClientId String? @map("download_client_id")
|
||||
downloadStatus String? @map("download_status")
|
||||
// Status values: queued, downloading, completed, failed, stalled
|
||||
@@ -302,6 +312,7 @@ model Job {
|
||||
requestId String? @map("request_id")
|
||||
type String
|
||||
// Job types: search_indexers, monitor_download, organize_files, scan_plex, plex_recently_added_check, match_plex
|
||||
// Ebook job types: search_ebook, start_direct_download, monitor_direct_download
|
||||
status String @default("pending")
|
||||
// Status values: pending, active, completed, failed, delayed, stuck
|
||||
priority Int @default(0)
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ActiveDownload {
|
||||
eta: number | null;
|
||||
user: string;
|
||||
startedAt: Date;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
}
|
||||
|
||||
interface ActiveDownloadsTableProps {
|
||||
@@ -77,7 +78,7 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Audiobook
|
||||
Request
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
User
|
||||
@@ -104,8 +105,21 @@ export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{download.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{download.title}
|
||||
</span>
|
||||
{download.type === 'ebook' && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{download.author}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface RecentRequest {
|
||||
title: string;
|
||||
author: string;
|
||||
status: string;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
userId: string;
|
||||
user: string;
|
||||
createdAt: Date;
|
||||
@@ -557,7 +558,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Audiobook
|
||||
Request
|
||||
<SortIcon field="title" currentSort={sortBy} currentOrder={sortOrder} />
|
||||
</div>
|
||||
</th>
|
||||
@@ -610,8 +611,21 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{request.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{request.title}
|
||||
</span>
|
||||
{request.type === 'ebook' && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
@@ -644,6 +658,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentReque
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
status: request.status,
|
||||
type: request.type,
|
||||
torrentUrl: request.torrentUrl,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface RequestActionsDropdownProps {
|
||||
title: string;
|
||||
author: string;
|
||||
status: string;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
torrentUrl?: string | null;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
@@ -39,17 +40,49 @@ export function RequestActionsDropdown({
|
||||
}: RequestActionsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
// Determine request type
|
||||
const isEbook = request.type === 'ebook';
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
// Only show "View Source" if we have a valid indexer page URL (not a magnet link)
|
||||
const canViewSource = !!request.torrentUrl &&
|
||||
!request.torrentUrl.startsWith('magnet:') &&
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
// For audiobooks and indexer-sourced ebooks, show indexer page URL (not magnet links)
|
||||
let viewSourceUrl: string | null = null;
|
||||
if (isEbook && request.torrentUrl) {
|
||||
// torrentUrl for ebooks can be:
|
||||
// 1. JSON array of slow download URLs (Anna's Archive) - extract MD5
|
||||
// 2. Plain URL string (indexer source) - use directly
|
||||
try {
|
||||
const urls = JSON.parse(request.torrentUrl);
|
||||
if (Array.isArray(urls) && urls.length > 0) {
|
||||
const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i);
|
||||
if (md5Match) {
|
||||
viewSourceUrl = `https://annas-archive.li/md5/${md5Match[1]}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON - it's a plain URL from indexer source
|
||||
// Use it directly if it's not a magnet link
|
||||
if (!request.torrentUrl.startsWith('magnet:')) {
|
||||
viewSourceUrl = request.torrentUrl;
|
||||
}
|
||||
}
|
||||
} else if (request.torrentUrl && !request.torrentUrl.startsWith('magnet:')) {
|
||||
viewSourceUrl = request.torrentUrl;
|
||||
}
|
||||
|
||||
const canViewSource = !!viewSourceUrl &&
|
||||
['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||
|
||||
// Ebook actions (Grab Ebook, Interactive Search Ebook) only for audiobook requests
|
||||
const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -82,6 +115,11 @@ export function RequestActionsDropdown({
|
||||
setShowInteractiveSearch(true);
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbook = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearchEbook(true);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
@@ -166,9 +204,9 @@ export function RequestActionsDropdown({
|
||||
)}
|
||||
|
||||
{/* View Source */}
|
||||
{canViewSource && (
|
||||
{canViewSource && viewSourceUrl && (
|
||||
<a
|
||||
href={request.torrentUrl!}
|
||||
href={viewSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setIsOpen(false)}
|
||||
@@ -192,7 +230,7 @@ export function RequestActionsDropdown({
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Fetch E-book */}
|
||||
{/* Grab E-book (automatic) */}
|
||||
{canFetchEbook && (
|
||||
<button
|
||||
onClick={handleFetchEbook}
|
||||
@@ -212,7 +250,31 @@ export function RequestActionsDropdown({
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
Try to fetch Ebook
|
||||
Grab Ebook
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Interactive Search E-book */}
|
||||
{canFetchEbook && (
|
||||
<button
|
||||
onClick={handleInteractiveSearchEbook}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
Interactive Search Ebook
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -300,7 +362,7 @@ export function RequestActionsDropdown({
|
||||
{/* Dropdown menu (rendered via portal) */}
|
||||
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||
|
||||
{/* Interactive Search Modal */}
|
||||
{/* Interactive Search Modal (Audiobook) */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearch}
|
||||
onClose={() => setShowInteractiveSearch(false)}
|
||||
@@ -310,6 +372,18 @@ export function RequestActionsDropdown({
|
||||
author: request.author,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Interactive Search Modal (Ebook) */}
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearchEbook}
|
||||
onClose={() => setShowInteractiveSearchEbook(false)}
|
||||
requestId={request.requestId}
|
||||
audiobook={{
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-4
@@ -18,6 +18,7 @@ import { useState } from 'react';
|
||||
interface PendingApprovalRequest {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
type: 'audiobook' | 'ebook';
|
||||
audiobook: {
|
||||
title: string;
|
||||
author: string;
|
||||
@@ -146,9 +147,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
{/* Book Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{request.audiobook.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{request.audiobook.title}
|
||||
</h3>
|
||||
{request.type === 'ebook' && (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.15)',
|
||||
color: '#f16f19',
|
||||
border: '1px solid rgba(241, 111, 25, 0.3)',
|
||||
}}
|
||||
>
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{request.audiobook.author}
|
||||
</p>
|
||||
@@ -489,7 +504,7 @@ function AdminDashboardContent() {
|
||||
Request Management
|
||||
</h2>
|
||||
<RecentRequestsTable
|
||||
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
|
||||
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,12 +103,18 @@ export interface PathsSettings {
|
||||
|
||||
/**
|
||||
* E-book sidecar configuration
|
||||
* Supports two sources: Anna's Archive (direct HTTP) and Indexer Search (Prowlarr)
|
||||
*/
|
||||
export interface EbookSettings {
|
||||
enabled: boolean;
|
||||
preferredFormat: string;
|
||||
// Source toggles
|
||||
annasArchiveEnabled: boolean;
|
||||
indexerSearchEnabled: boolean;
|
||||
// Anna's Archive specific settings
|
||||
baseUrl: string;
|
||||
flaresolverrUrl: string;
|
||||
// General settings (shared across sources)
|
||||
preferredFormat: string;
|
||||
autoGrabEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +149,8 @@ export interface IndexerConfig {
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
categories?: number[];
|
||||
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
|
||||
ebookCategories?: number[]; // Category IDs for ebook searches (default: [7020])
|
||||
supportsRss?: boolean;
|
||||
}
|
||||
|
||||
@@ -158,7 +165,8 @@ export interface SavedIndexerConfig {
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
|
||||
ebookCategories: number[]; // Category IDs for ebook searches (default: [7020])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,7 +106,8 @@ export default function AdminSettings() {
|
||||
protocol: idx.protocol,
|
||||
priority: idx.priority,
|
||||
rssEnabled: idx.rssEnabled,
|
||||
categories: idx.categories || [3030],
|
||||
audiobookCategories: idx.audiobookCategories || [3030],
|
||||
ebookCategories: idx.ebookCategories || [7020],
|
||||
};
|
||||
|
||||
// Add protocol-specific fields
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Component: E-book Settings Tab
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*
|
||||
* Three-section layout:
|
||||
* 1. Anna's Archive - Direct HTTP downloads from Anna's Archive
|
||||
* 2. Indexer Search - Search via Prowlarr indexers (future feature)
|
||||
* 3. General Settings - Shared settings like preferred format
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -27,167 +32,246 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
|
||||
updateEbook,
|
||||
testFlaresolverrConnection,
|
||||
saveSettings,
|
||||
isAnySourceEnabled,
|
||||
} = useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
E-book Sidecar
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Automatically download e-books from Anna's Archive to accompany your audiobooks.
|
||||
Automatically download e-books to accompany your audiobooks.
|
||||
E-books are placed in the same folder as the audiobook files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ebook-enabled"
|
||||
checked={ebook.enabled || false}
|
||||
onChange={(e) => updateEbook('enabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="ebook-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable e-book sidecar downloads
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When enabled, the system will search for e-books matching your audiobook's ASIN
|
||||
and download them to the same folder.
|
||||
</p>
|
||||
{/* ═══════════════════════════════════════════════════════════════════════
|
||||
SECTION 1: ANNA'S ARCHIVE
|
||||
═══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider">
|
||||
Anna's Archive
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="annas-archive-enabled"
|
||||
checked={ebook.annasArchiveEnabled || false}
|
||||
onChange={(e) => updateEbook('annasArchiveEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="annas-archive-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Anna's Archive downloads
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Download e-books directly from Anna's Archive using ASIN or title matching.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anna's Archive specific settings - only shown when enabled */}
|
||||
{ebook.annasArchiveEnabled && (
|
||||
<>
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Change this if the primary Anna's Archive mirror is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* FlareSolverr URL */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
FlareSolverr URL (Optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.flaresolverrUrl || ''}
|
||||
onChange={(e) => updateEbook('flaresolverrUrl', e.target.value)}
|
||||
placeholder="http://localhost:8191"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={testFlaresolverrConnection}
|
||||
loading={testingFlaresolverr}
|
||||
variant="secondary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
FlareSolverr helps bypass Cloudflare protection.
|
||||
</p>
|
||||
{flaresolverrTestResult && (
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-lg text-sm ${
|
||||
flaresolverrTestResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
{flaresolverrTestResult.success ? '✓ ' : '✗ '}
|
||||
{flaresolverrTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!ebook.flaresolverrUrl && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
|
||||
has Cloudflare protection enabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
{ebook.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Preferred Format
|
||||
</label>
|
||||
<select
|
||||
value={ebook.preferredFormat || 'epub'}
|
||||
onChange={(e) => updateEbook('preferredFormat', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="epub">EPUB</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mobi">MOBI</option>
|
||||
<option value="azw3">AZW3</option>
|
||||
<option value="any">Any format</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
EPUB is recommended for most e-readers. "Any format" will download the first available format.
|
||||
</p>
|
||||
{/* ═══════════════════════════════════════════════════════════════════════
|
||||
SECTION 2: INDEXER SEARCH
|
||||
═══════════════════════════════════════════════════════════════════════ */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider">
|
||||
Indexer Search
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL (Advanced) */}
|
||||
{ebook.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL (Advanced)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Change this if the primary Anna's Archive mirror is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FlareSolverr (Optional - for Cloudflare bypass) */}
|
||||
{ebook.enabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
FlareSolverr URL (Optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.flaresolverrUrl || ''}
|
||||
onChange={(e) => updateEbook('flaresolverrUrl', e.target.value)}
|
||||
placeholder="http://localhost:8191"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={testFlaresolverrConnection}
|
||||
loading={testingFlaresolverr}
|
||||
variant="secondary"
|
||||
className="whitespace-nowrap"
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="indexer-search-enabled"
|
||||
checked={ebook.indexerSearchEnabled || false}
|
||||
onChange={(e) => updateEbook('indexerSearchEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="indexer-search-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
Enable Indexer Search
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Search for e-books via Prowlarr indexers (torrent/NZB sources).
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
FlareSolverr helps bypass Cloudflare protection on Anna's Archive.
|
||||
Leave empty if not needed.
|
||||
</p>
|
||||
{flaresolverrTestResult && (
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-lg text-sm ${
|
||||
flaresolverrTestResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
{flaresolverrTestResult.success ? '✓ ' : '✗ '}
|
||||
{flaresolverrTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!ebook.flaresolverrUrl && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
|
||||
has Cloudflare protection enabled. Success rates are typically lower without it.
|
||||
|
||||
{/* Info hint about indexer settings */}
|
||||
{ebook.indexerSearchEnabled && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Configure Categories:</strong> E-book category settings are configured per-indexer
|
||||
in the <span className="font-medium">Indexers</span> tab. Look for the "EBook" tab when
|
||||
editing an indexer.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════════════
|
||||
SECTION 3: GENERAL SETTINGS
|
||||
═══════════════════════════════════════════════════════════════════════ */}
|
||||
{isAnySourceEnabled && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wider">
|
||||
General Settings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Preferred Format */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Preferred Format
|
||||
</label>
|
||||
<select
|
||||
value={ebook.preferredFormat || 'epub'}
|
||||
onChange={(e) => updateEbook('preferredFormat', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="epub">EPUB (Recommended)</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mobi">MOBI</option>
|
||||
<option value="azw3">AZW3</option>
|
||||
<option value="any">Any format</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
EPUB is recommended for most e-readers. "Any format" accepts the first available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Grab Toggle */}
|
||||
<div className="flex items-start gap-4 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto-grab-enabled"
|
||||
checked={ebook.autoGrabEnabled ?? true}
|
||||
onChange={(e) => updateEbook('autoGrabEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="auto-grab-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Automatically fetch ebooks
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When enabled, ebook requests are created automatically after audiobook downloads complete.
|
||||
When disabled, use the "Fetch Ebook" button on completed requests.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
How it works
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• Searches Anna's Archive in two ways:</li>
|
||||
<li className="ml-4">1. First tries ASIN (exact match - most accurate)</li>
|
||||
<li className="ml-4">2. Falls back to title + author (with book/language filters)</li>
|
||||
<li>• Downloads matching e-book in your preferred format</li>
|
||||
<li>• Places e-book file in the same folder as the audiobook</li>
|
||||
<li>• If no match is found or download fails, audiobook download continues normally</li>
|
||||
<li>• Completely optional and non-blocking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
||||
⚠️ Important Note
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility.
|
||||
Ensure compliance with your local laws and regulations.
|
||||
</p>
|
||||
</div>
|
||||
{/* How it works - only show when Anna's Archive is enabled */}
|
||||
{ebook.annasArchiveEnabled && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
How Anna's Archive works
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• Searches by ASIN first (exact match), then title + author</li>
|
||||
<li>• Downloads matching e-book in your preferred format</li>
|
||||
<li>• Places e-book file in the same folder as the audiobook</li>
|
||||
<li>• If no match is found, audiobook download continues normally</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
|
||||
@@ -77,10 +77,12 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: ebook.enabled || false,
|
||||
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
|
||||
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
|
||||
format: ebook.preferredFormat || 'epub',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -98,6 +100,11 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to check if any ebook source is enabled
|
||||
*/
|
||||
const isAnySourceEnabled = ebook.annasArchiveEnabled || ebook.indexerSearchEnabled;
|
||||
|
||||
return {
|
||||
saving,
|
||||
testingFlaresolverr,
|
||||
@@ -105,5 +112,6 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
updateEbook,
|
||||
testFlaresolverrConnection,
|
||||
saveSettings,
|
||||
isAnySourceEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Get active downloads with related data
|
||||
// Get active downloads with related data (both audiobook and ebook)
|
||||
const activeDownloads = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'downloading',
|
||||
@@ -26,6 +26,7 @@ export async function GET(request: NextRequest) {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
type: true, // 'audiobook' or 'ebook'
|
||||
progress: true,
|
||||
updatedAt: true,
|
||||
audiobook: {
|
||||
@@ -54,6 +55,8 @@ export async function GET(request: NextRequest) {
|
||||
torrentName: true,
|
||||
torrentHash: true,
|
||||
nzbId: true,
|
||||
downloadClient: true, // qbittorrent, sabnzbd, or direct
|
||||
torrentSizeBytes: true,
|
||||
startedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
@@ -75,19 +78,38 @@ export async function GET(request: NextRequest) {
|
||||
let speed = 0;
|
||||
let eta: number | null = null;
|
||||
|
||||
const downloadHistory = download.downloadHistory[0];
|
||||
const downloadClient = downloadHistory?.downloadClient;
|
||||
|
||||
try {
|
||||
if (clientType === 'qbittorrent') {
|
||||
if (downloadClient === 'direct') {
|
||||
// Direct HTTP download (ebooks) - estimate speed from progress and time elapsed
|
||||
const startedAt = downloadHistory?.startedAt || downloadHistory?.createdAt;
|
||||
const totalSize = downloadHistory?.torrentSizeBytes ? Number(downloadHistory.torrentSizeBytes) : 0;
|
||||
|
||||
if (startedAt && download.progress > 0 && totalSize > 0) {
|
||||
const elapsedMs = Date.now() - new Date(startedAt).getTime();
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const bytesDownloaded = (download.progress / 100) * totalSize;
|
||||
|
||||
if (elapsedSeconds > 0) {
|
||||
speed = Math.round(bytesDownloaded / elapsedSeconds);
|
||||
const remainingBytes = totalSize - bytesDownloaded;
|
||||
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
|
||||
}
|
||||
}
|
||||
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
|
||||
// Get torrent hash from download history
|
||||
const torrentHash = download.downloadHistory[0]?.torrentHash;
|
||||
const torrentHash = downloadHistory?.torrentHash;
|
||||
if (torrentHash) {
|
||||
const qbService = await getQBittorrentService();
|
||||
const torrentInfo = await qbService.getTorrent(torrentHash);
|
||||
speed = torrentInfo.dlspeed;
|
||||
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
|
||||
}
|
||||
} else if (clientType === 'sabnzbd') {
|
||||
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
|
||||
// Get NZB ID from download history
|
||||
const nzbId = download.downloadHistory[0]?.nzbId;
|
||||
const nzbId = downloadHistory?.nzbId;
|
||||
if (nzbId) {
|
||||
const sabnzbdService = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbdService.getNZB(nzbId);
|
||||
@@ -107,13 +129,14 @@ export async function GET(request: NextRequest) {
|
||||
title: download.audiobook.title,
|
||||
author: download.audiobook.author,
|
||||
status: download.status,
|
||||
type: download.type, // 'audiobook' or 'ebook'
|
||||
progress: download.progress,
|
||||
speed,
|
||||
eta,
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
torrentName: downloadHistory?.torrentName || null,
|
||||
downloadStatus: downloadHistory?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
startedAt: downloadHistory?.startedAt || downloadHistory?.createdAt || download.updatedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -76,26 +76,67 @@ export async function POST(
|
||||
// Update request based on action
|
||||
if (action === 'approve') {
|
||||
const jobQueue = getJobQueueService();
|
||||
const isEbookRequest = existingRequest.type === 'ebook';
|
||||
|
||||
// Check if request has a pre-selected torrent (from interactive search)
|
||||
if (existingRequest.selectedTorrent) {
|
||||
const selectedTorrent = existingRequest.selectedTorrent as any;
|
||||
|
||||
// User pre-selected a specific torrent - download that torrent directly
|
||||
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
|
||||
requestId: id,
|
||||
userId: existingRequest.userId,
|
||||
adminId: req.user.sub,
|
||||
type: existingRequest.type,
|
||||
source: selectedTorrent.source,
|
||||
});
|
||||
|
||||
// Trigger download job with pre-selected torrent
|
||||
await jobQueue.addDownloadJob(
|
||||
existingRequest.id,
|
||||
{
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
},
|
||||
existingRequest.selectedTorrent as any
|
||||
);
|
||||
// Handle ebook requests with Anna's Archive source differently
|
||||
if (isEbookRequest && selectedTorrent.source === 'annas_archive') {
|
||||
// Create download history record for Anna's Archive
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId: existingRequest.id,
|
||||
indexerName: "Anna's Archive",
|
||||
torrentName: `${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`,
|
||||
torrentSizeBytes: null,
|
||||
qualityScore: selectedTorrent.score || 100,
|
||||
selected: true,
|
||||
downloadClient: 'direct',
|
||||
downloadStatus: 'queued',
|
||||
},
|
||||
});
|
||||
|
||||
// Store all download URLs for retry purposes
|
||||
if (selectedTorrent.downloadUrls && selectedTorrent.downloadUrls.length > 0) {
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistory.id },
|
||||
data: {
|
||||
torrentUrl: JSON.stringify(selectedTorrent.downloadUrls),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger direct download job for Anna's Archive
|
||||
await jobQueue.addStartDirectDownloadJob(
|
||||
existingRequest.id,
|
||||
downloadHistory.id,
|
||||
selectedTorrent.downloadUrl,
|
||||
`${existingRequest.audiobook.title} - ${existingRequest.audiobook.author}.${selectedTorrent.format || 'epub'}`,
|
||||
undefined
|
||||
);
|
||||
} else {
|
||||
// Trigger download job with pre-selected torrent (audiobook or indexer ebook)
|
||||
await jobQueue.addDownloadJob(
|
||||
existingRequest.id,
|
||||
{
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
},
|
||||
selectedTorrent
|
||||
);
|
||||
}
|
||||
|
||||
// Update status to 'downloading' and clear selectedTorrent
|
||||
const updatedRequest = await prisma.request.update({
|
||||
@@ -119,7 +160,7 @@ export async function POST(
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
updatedRequest.id,
|
||||
existingRequest.audiobook.title,
|
||||
isEbookRequest ? `${existingRequest.audiobook.title} (Ebook)` : existingRequest.audiobook.title,
|
||||
existingRequest.audiobook.author,
|
||||
existingRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
@@ -131,6 +172,7 @@ export async function POST(
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: existingRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
type: existingRequest.type,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -144,6 +186,7 @@ export async function POST(
|
||||
requestId: id,
|
||||
userId: existingRequest.userId,
|
||||
adminId: req.user.sub,
|
||||
type: existingRequest.type,
|
||||
});
|
||||
|
||||
const updatedRequest = await prisma.request.update({
|
||||
@@ -160,19 +203,28 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger search job
|
||||
await jobQueue.addSearchJob(updatedRequest.id, {
|
||||
id: updatedRequest.audiobook.id,
|
||||
title: updatedRequest.audiobook.title,
|
||||
author: updatedRequest.audiobook.author,
|
||||
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
// Trigger appropriate search job based on request type
|
||||
if (isEbookRequest) {
|
||||
await jobQueue.addSearchEbookJob(updatedRequest.id, {
|
||||
id: updatedRequest.audiobook.id,
|
||||
title: updatedRequest.audiobook.title,
|
||||
author: updatedRequest.audiobook.author,
|
||||
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
} else {
|
||||
await jobQueue.addSearchJob(updatedRequest.id, {
|
||||
id: updatedRequest.audiobook.id,
|
||||
title: updatedRequest.audiobook.title,
|
||||
author: updatedRequest.audiobook.author,
|
||||
asin: updatedRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification for manual approval
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
updatedRequest.id,
|
||||
updatedRequest.audiobook.title,
|
||||
isEbookRequest ? `${updatedRequest.audiobook.title} (Ebook)` : updatedRequest.audiobook.title,
|
||||
updatedRequest.audiobook.author,
|
||||
updatedRequest.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
@@ -184,11 +236,14 @@ export async function POST(
|
||||
userId: updatedRequest.userId,
|
||||
audiobookTitle: updatedRequest.audiobook.title,
|
||||
adminId: req.user.sub,
|
||||
type: existingRequest.type,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Request approved and search job triggered',
|
||||
message: isEbookRequest
|
||||
? 'Ebook request approved and ebook search job triggered'
|
||||
: 'Request approved and search job triggered',
|
||||
request: updatedRequest,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
status: request.status,
|
||||
type: request.type, // 'audiobook' or 'ebook'
|
||||
user: request.user.plexUsername,
|
||||
createdAt: request.createdAt,
|
||||
completedAt: request.completedAt,
|
||||
|
||||
@@ -130,6 +130,7 @@ export async function GET(request: NextRequest) {
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
status: request.status,
|
||||
type: request.type || 'audiobook', // Include request type for UI display
|
||||
userId: request.user.id,
|
||||
user: request.user.plexUsername,
|
||||
createdAt: request.createdAt,
|
||||
|
||||
@@ -13,8 +13,11 @@ export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
// Parse request body
|
||||
const { enabled, format, baseUrl, flaresolverrUrl } = await request.json();
|
||||
// Parse request body - new structure with separate source toggles
|
||||
const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl, autoGrabEnabled } = await request.json();
|
||||
|
||||
// Enforce: auto-grab must be false if no sources are enabled
|
||||
const effectiveAutoGrabEnabled = (annasArchiveEnabled || indexerSearchEnabled) ? (autoGrabEnabled ?? true) : false;
|
||||
|
||||
// Validate format
|
||||
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
|
||||
@@ -25,8 +28,8 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate baseUrl (basic check)
|
||||
if (baseUrl && !baseUrl.startsWith('http')) {
|
||||
// Validate baseUrl (basic check) - only required if Anna's Archive is enabled
|
||||
if (annasArchiveEnabled && baseUrl && !baseUrl.startsWith('http')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
@@ -46,23 +49,38 @@ export async function PUT(request: NextRequest) {
|
||||
const configService = getConfigService();
|
||||
|
||||
const configs = [
|
||||
// New granular source toggles
|
||||
{
|
||||
key: 'ebook_sidecar_enabled',
|
||||
value: enabled ? 'true' : 'false',
|
||||
key: 'ebook_annas_archive_enabled',
|
||||
value: annasArchiveEnabled ? 'true' : 'false',
|
||||
category: 'ebook',
|
||||
description: 'Enable e-book sidecar downloads from Annas Archive',
|
||||
description: 'Enable e-book downloads from Anna\'s Archive',
|
||||
},
|
||||
{
|
||||
key: 'ebook_indexer_search_enabled',
|
||||
value: indexerSearchEnabled ? 'true' : 'false',
|
||||
category: 'ebook',
|
||||
description: 'Enable e-book downloads via indexer search (Prowlarr)',
|
||||
},
|
||||
// General settings
|
||||
{
|
||||
key: 'ebook_sidecar_preferred_format',
|
||||
value: format || 'epub',
|
||||
category: 'ebook',
|
||||
description: 'Preferred e-book format',
|
||||
},
|
||||
{
|
||||
key: 'ebook_auto_grab_enabled',
|
||||
value: effectiveAutoGrabEnabled ? 'true' : 'false',
|
||||
category: 'ebook',
|
||||
description: 'Automatically create ebook requests after audiobook downloads complete',
|
||||
},
|
||||
// Anna's Archive specific settings
|
||||
{
|
||||
key: 'ebook_sidecar_base_url',
|
||||
value: baseUrl || 'https://annas-archive.li',
|
||||
category: 'ebook',
|
||||
description: 'Base URL for Annas Archive',
|
||||
description: 'Base URL for Anna\'s Archive',
|
||||
},
|
||||
{
|
||||
key: 'ebook_sidecar_flaresolverr_url',
|
||||
|
||||
@@ -19,7 +19,9 @@ interface SavedIndexerConfig {
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled?: boolean;
|
||||
categories?: number[]; // Array of category IDs (default: [3030] for audiobooks)
|
||||
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
|
||||
ebookCategories?: number[]; // Array of category IDs for ebooks (default: [7020])
|
||||
categories?: number[]; // Legacy field for migration
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +56,12 @@ export async function GET(request: NextRequest) {
|
||||
const isAdded = !!saved;
|
||||
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
|
||||
|
||||
// Migration: if old 'categories' field exists but new fields don't, migrate
|
||||
const migratedAudiobookCategories = saved?.audiobookCategories ||
|
||||
saved?.categories || // Legacy migration
|
||||
[3030]; // Default to audiobooks category
|
||||
const migratedEbookCategories = saved?.ebookCategories || [7020]; // Default to ebooks category
|
||||
|
||||
const config: any = {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
@@ -63,7 +71,8 @@ export async function GET(request: NextRequest) {
|
||||
isAdded, // Explicit flag for UI (new card-based interface)
|
||||
priority: saved?.priority || 10,
|
||||
rssEnabled: saved?.rssEnabled ?? false,
|
||||
categories: saved?.categories || [3030], // Default to audiobooks category
|
||||
audiobookCategories: migratedAudiobookCategories,
|
||||
ebookCategories: migratedEbookCategories,
|
||||
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
|
||||
};
|
||||
|
||||
@@ -117,7 +126,8 @@ export async function PUT(request: NextRequest) {
|
||||
protocol: indexer.protocol,
|
||||
priority: indexer.priority,
|
||||
rssEnabled: indexer.rssEnabled || false,
|
||||
categories: indexer.categories || [3030], // Default to audiobooks if not specified
|
||||
audiobookCategories: indexer.audiobookCategories || [3030], // Default to audiobooks
|
||||
ebookCategories: indexer.ebookCategories || [7020], // Default to ebooks
|
||||
};
|
||||
|
||||
// Add protocol-specific fields
|
||||
|
||||
@@ -129,10 +129,18 @@ export async function GET(request: NextRequest) {
|
||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||
},
|
||||
ebook: {
|
||||
enabled: configMap.get('ebook_sidecar_enabled') === 'true',
|
||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||
annasArchiveEnabled: configMap.get('ebook_annas_archive_enabled') === 'true' ||
|
||||
// Migration: if old key is true and new key doesn't exist, use old value
|
||||
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
|
||||
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
|
||||
// Anna's Archive specific settings
|
||||
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
|
||||
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
||||
// General settings
|
||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
||||
// Auto-grab: default true to preserve existing behavior
|
||||
autoGrabEnabled: configMap.get('ebook_auto_grab_enabled') !== 'false',
|
||||
},
|
||||
general: {
|
||||
appName: configMap.get('app_name') || 'ReadMeABook',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Component: Ebook Status API Route
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Returns ebook availability status for a specific audiobook
|
||||
* Used by AudiobookDetailsModal to determine if ebook buttons should be shown
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.EbookStatus');
|
||||
|
||||
// Statuses that indicate an active/in-progress ebook request
|
||||
const ACTIVE_EBOOK_STATUSES = [
|
||||
'pending',
|
||||
'awaiting_approval',
|
||||
'searching',
|
||||
'downloading',
|
||||
'processing',
|
||||
'downloaded',
|
||||
'available',
|
||||
];
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/[asin]/ebook-status
|
||||
* Returns whether ebook sources are enabled and if an active ebook request exists
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { asin } = await params;
|
||||
|
||||
if (!asin || asin.length !== 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Valid ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check which ebook sources are enabled
|
||||
const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }),
|
||||
]);
|
||||
|
||||
// Legacy migration: check old key if new keys don't exist
|
||||
const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' ||
|
||||
(annasArchiveConfig === null && legacyConfig?.value === 'true');
|
||||
const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true';
|
||||
const ebookSourcesEnabled = isAnnasArchiveEnabled || isIndexerSearchEnabled;
|
||||
|
||||
// If no ebook sources enabled, return early
|
||||
if (!ebookSourcesEnabled) {
|
||||
return NextResponse.json({
|
||||
ebookSourcesEnabled: false,
|
||||
hasActiveEbookRequest: false,
|
||||
existingEbookStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Find the audiobook by ASIN
|
||||
const audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
// Audiobook not in database - that's fine, just no ebook request possible
|
||||
return NextResponse.json({
|
||||
ebookSourcesEnabled: true,
|
||||
hasActiveEbookRequest: false,
|
||||
existingEbookStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for any active ebook request for this audiobook
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
status: { in: ACTIVE_EBOOK_STATUSES },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ebookSourcesEnabled: true,
|
||||
hasActiveEbookRequest: !!existingEbookRequest,
|
||||
existingEbookStatus: existingEbookRequest?.status || null,
|
||||
existingEbookRequestId: existingEbookRequest?.id || null,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ebook status', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch ebook status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Component: Fetch Ebook by ASIN API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Creates an ebook request for an available audiobook (by ASIN)
|
||||
* Supports both audiobooks with parent requests and orphan audiobooks (imported outside RMAB)
|
||||
* Includes approval logic for non-admin users
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.FetchEbook');
|
||||
|
||||
// Statuses that indicate an active/in-progress ebook request
|
||||
const ACTIVE_EBOOK_STATUSES = [
|
||||
'pending',
|
||||
'awaiting_approval',
|
||||
'searching',
|
||||
'downloading',
|
||||
'processing',
|
||||
'downloaded',
|
||||
'available',
|
||||
];
|
||||
|
||||
// Statuses that allow retry
|
||||
const RETRYABLE_STATUSES = ['failed', 'awaiting_search'];
|
||||
|
||||
/**
|
||||
* POST /api/audiobooks/[asin]/fetch-ebook
|
||||
* Create an ebook request for an available audiobook
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { asin } = await params;
|
||||
|
||||
if (!asin || asin.length !== 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Valid ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check which ebook sources are enabled
|
||||
const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }),
|
||||
]);
|
||||
|
||||
const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' ||
|
||||
(annasArchiveConfig === null && legacyConfig?.value === 'true');
|
||||
const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true';
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-book feature is not enabled (no sources configured)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// First, check if the audiobook is available in Plex library
|
||||
// This works even for books imported outside RMAB
|
||||
const audibleService = getAudibleService();
|
||||
let audibleData = null;
|
||||
try {
|
||||
audibleData = await audibleService.getAudiobookDetails(asin);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
if (!audibleData) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found on Audible' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check Plex availability using Audible metadata
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin,
|
||||
title: audibleData.title,
|
||||
author: audibleData.author,
|
||||
});
|
||||
|
||||
// Find or create audiobook record
|
||||
let audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
|
||||
// Check for available request if audiobook exists in database
|
||||
let availableRequest = null;
|
||||
if (audiobook) {
|
||||
availableRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'audiobook',
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = !!availableRequest || !!plexMatch;
|
||||
|
||||
if (!isAvailable) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook must be available in your library before requesting an ebook' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If audiobook doesn't exist in database but is in Plex, create it
|
||||
if (!audiobook) {
|
||||
logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`);
|
||||
|
||||
// Extract year from release date
|
||||
let year: number | undefined;
|
||||
if (audibleData.releaseDate) {
|
||||
try {
|
||||
const releaseYear = new Date(audibleData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
audiobook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: audibleData.title,
|
||||
author: audibleData.author,
|
||||
narrator: audibleData.narrator,
|
||||
description: audibleData.description,
|
||||
coverArtUrl: audibleData.coverArtUrl,
|
||||
year,
|
||||
series: audibleData.series,
|
||||
seriesPart: audibleData.seriesPart,
|
||||
status: 'available', // Mark as available since it's in Plex
|
||||
},
|
||||
});
|
||||
logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`);
|
||||
}
|
||||
|
||||
// Check for existing ebook request for this audiobook
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Handle existing ebook request
|
||||
if (existingEbookRequest) {
|
||||
// If in active status, block
|
||||
if (ACTIVE_EBOOK_STATUSES.includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
requestId: existingEbookRequest.id,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
// If retryable, reset and retry
|
||||
if (RETRYABLE_STATUSES.includes(existingEbookRequest.status)) {
|
||||
await prisma.request.update({
|
||||
where: { id: existingEbookRequest.id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(existingEbookRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${audiobook.title}"`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'E-book search retried',
|
||||
requestId: existingEbookRequest.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if approval is needed for non-admin users
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let needsApproval = false;
|
||||
|
||||
if (user.role === 'admin') {
|
||||
needsApproval = false;
|
||||
} else {
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
// User setting is null, check global setting
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
if (needsApproval) {
|
||||
// Create ebook request with awaiting_approval status
|
||||
const ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Send pending approval notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
ebookRequest.id,
|
||||
`${audiobook.title} (Ebook)`,
|
||||
audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Ebook request ${ebookRequest.id} created, awaiting admin approval`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Ebook request submitted for admin approval',
|
||||
requestId: ebookRequest.id,
|
||||
needsApproval: true,
|
||||
}, { status: 201 });
|
||||
} else {
|
||||
// Auto-approved - create request and start search
|
||||
const ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created ebook request ${ebookRequest.id} for "${audiobook.title}"`);
|
||||
|
||||
// Trigger ebook search job
|
||||
await jobQueue.addSearchEbookJob(ebookRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
// Send approved notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
ebookRequest.id,
|
||||
`${audiobook.title} (Ebook)`,
|
||||
audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'E-book request created and search started',
|
||||
requestId: ebookRequest.id,
|
||||
needsApproval: false,
|
||||
}, { status: 201 });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Component: Interactive Search Ebook by ASIN API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Searches for ebooks from multiple sources (Anna's Archive + Indexers)
|
||||
* Returns combined results for user selection in interactive modal
|
||||
* User-accessible endpoint (not admin-only)
|
||||
*/
|
||||
|
||||
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 { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
getSlowDownloadLinks,
|
||||
} from '@/lib/services/ebook-scraper';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.InteractiveSearchEbook');
|
||||
|
||||
// Statuses that indicate an active/in-progress ebook request
|
||||
const ACTIVE_EBOOK_STATUSES = [
|
||||
'pending',
|
||||
'awaiting_approval',
|
||||
'searching',
|
||||
'downloading',
|
||||
'processing',
|
||||
'downloaded',
|
||||
'available',
|
||||
];
|
||||
|
||||
// Statuses that allow retry via interactive search
|
||||
const RETRYABLE_STATUSES = ['failed', 'awaiting_search'];
|
||||
|
||||
// Unified result type for frontend
|
||||
export interface EbookSearchResult {
|
||||
guid: string;
|
||||
title: string;
|
||||
size: number;
|
||||
seeders?: number;
|
||||
indexer: string;
|
||||
indexerId?: number;
|
||||
publishDate: Date;
|
||||
downloadUrl: string;
|
||||
infoUrl?: string;
|
||||
protocol?: string;
|
||||
|
||||
score: number;
|
||||
finalScore: number;
|
||||
bonusPoints: number;
|
||||
bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>;
|
||||
rank: number;
|
||||
breakdown: {
|
||||
formatScore: number;
|
||||
sizeScore: number;
|
||||
seederScore: number;
|
||||
matchScore: number;
|
||||
totalScore: number;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
source: 'annas_archive' | 'prowlarr';
|
||||
format?: string;
|
||||
md5?: string;
|
||||
downloadUrls?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/audiobooks/[asin]/interactive-search-ebook
|
||||
* Search for ebooks and return results for user selection
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { asin } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const customTitle = body.customTitle as string | undefined;
|
||||
|
||||
if (!asin || asin.length !== 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Valid ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// First, fetch audiobook data from Audible (works for books imported outside RMAB)
|
||||
const audibleService = getAudibleService();
|
||||
let audibleData = null;
|
||||
try {
|
||||
audibleData = await audibleService.getAudiobookDetails(asin);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
if (!audibleData) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found on Audible' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check Plex availability using Audible metadata
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin,
|
||||
title: audibleData.title,
|
||||
author: audibleData.author,
|
||||
});
|
||||
|
||||
// Find or create audiobook record
|
||||
let audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
|
||||
// Check for available request if audiobook exists in database
|
||||
let availableRequest = null;
|
||||
if (audiobook) {
|
||||
availableRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'audiobook',
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = !!availableRequest || !!plexMatch;
|
||||
|
||||
if (!isAvailable) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook must be available in your library before searching for ebooks' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If audiobook doesn't exist in database but is in Plex, create it
|
||||
if (!audiobook) {
|
||||
logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`);
|
||||
|
||||
// Extract year from release date
|
||||
let year: number | undefined;
|
||||
if (audibleData.releaseDate) {
|
||||
try {
|
||||
const releaseYear = new Date(audibleData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
audiobook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: audibleData.title,
|
||||
author: audibleData.author,
|
||||
narrator: audibleData.narrator,
|
||||
description: audibleData.description,
|
||||
coverArtUrl: audibleData.coverArtUrl,
|
||||
year,
|
||||
series: audibleData.series,
|
||||
seriesPart: audibleData.seriesPart,
|
||||
status: 'available',
|
||||
},
|
||||
});
|
||||
logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`);
|
||||
}
|
||||
|
||||
// Check for existing non-retryable ebook request
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (existingEbookRequest &&
|
||||
ACTIVE_EBOOK_STATUSES.includes(existingEbookRequest.status) &&
|
||||
!RETRYABLE_STATUSES.includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
existingRequestId: existingEbookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get ebook configuration
|
||||
const configService = getConfigService();
|
||||
const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([
|
||||
configService.get('ebook_annas_archive_enabled'),
|
||||
configService.get('ebook_indexer_search_enabled'),
|
||||
configService.get('ebook_sidecar_preferred_format'),
|
||||
configService.get('ebook_sidecar_base_url'),
|
||||
configService.get('ebook_sidecar_flaresolverr_url'),
|
||||
]);
|
||||
|
||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const searchTitle = customTitle || audiobook.title;
|
||||
|
||||
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
|
||||
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
|
||||
|
||||
// Search both sources in parallel
|
||||
const searchPromises: Promise<EbookSearchResult[] | null>[] = [];
|
||||
|
||||
if (isAnnasArchiveEnabled) {
|
||||
searchPromises.push(
|
||||
searchAnnasArchiveForInteractive(
|
||||
audiobook.audibleAsin || undefined,
|
||||
searchTitle,
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isIndexerSearchEnabled) {
|
||||
searchPromises.push(
|
||||
searchIndexersForInteractive(
|
||||
searchTitle,
|
||||
audiobook.author,
|
||||
format
|
||||
).catch((err) => {
|
||||
logger.error(`Indexer search failed: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const searchResults = await Promise.all(searchPromises);
|
||||
|
||||
// Combine results: Anna's Archive first (if found), then ranked indexer results
|
||||
const combinedResults: EbookSearchResult[] = [];
|
||||
let rank = 1;
|
||||
|
||||
// Add Anna's Archive result first (if enabled and found)
|
||||
if (isAnnasArchiveEnabled && searchResults[0]) {
|
||||
const annasResults = searchResults[0];
|
||||
for (const result of annasResults) {
|
||||
combinedResults.push({ ...result, rank: rank++ });
|
||||
}
|
||||
}
|
||||
|
||||
// Add indexer results (already ranked)
|
||||
const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0;
|
||||
if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) {
|
||||
const indexerResults = searchResults[indexerResultsIndex];
|
||||
for (const result of indexerResults) {
|
||||
combinedResults.push({ ...result, rank: rank++ });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${combinedResults.length} total ebook results`);
|
||||
|
||||
return NextResponse.json({
|
||||
results: combinedResults,
|
||||
searchTitle,
|
||||
preferredFormat: format,
|
||||
audiobookId: audiobook.id,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Anna's Archive and return normalized results
|
||||
*/
|
||||
async function searchAnnasArchiveForInteractive(
|
||||
asin: string | undefined,
|
||||
title: string,
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
|
||||
// Try ASIN search first
|
||||
if (asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
if (md5) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
if (md5) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!md5) {
|
||||
logger.info('No results from Anna\'s Archive');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get download links
|
||||
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl);
|
||||
|
||||
if (slowLinks.length === 0) {
|
||||
logger.warn(`Found MD5 ${md5} but no download links available`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return as normalized result - always score 100 for Anna's Archive
|
||||
const score = 100;
|
||||
|
||||
return [{
|
||||
guid: `annas-archive-${md5}`,
|
||||
title: `${title} - ${author}`,
|
||||
size: 0,
|
||||
seeders: 999,
|
||||
indexer: "Anna's Archive",
|
||||
publishDate: new Date(),
|
||||
downloadUrl: slowLinks[0],
|
||||
infoUrl: `${baseUrl}/md5/${md5}`,
|
||||
|
||||
score,
|
||||
finalScore: score,
|
||||
bonusPoints: 0,
|
||||
bonusModifiers: [],
|
||||
rank: 1,
|
||||
breakdown: {
|
||||
formatScore: 10,
|
||||
sizeScore: 15,
|
||||
seederScore: 15,
|
||||
matchScore: 60,
|
||||
totalScore: score,
|
||||
notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"],
|
||||
},
|
||||
|
||||
source: 'annas_archive',
|
||||
format: preferredFormat,
|
||||
md5,
|
||||
downloadUrls: slowLinks,
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexers and return ranked results
|
||||
*/
|
||||
async function searchIndexersForInteractive(
|
||||
title: string,
|
||||
author: string,
|
||||
preferredFormat: string
|
||||
): Promise<EbookSearchResult[]> {
|
||||
const configService = getConfigService();
|
||||
|
||||
// Get indexer configuration
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
if (!indexersConfigStr) {
|
||||
logger.warn('No indexers configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
if (indexersConfig.length === 0) {
|
||||
logger.warn('No indexers enabled');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build indexer priorities map
|
||||
const indexerPriorities = new Map<number, number>(
|
||||
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
|
||||
);
|
||||
|
||||
// Get flag configurations
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Group indexers by ebook categories
|
||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
|
||||
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
|
||||
// Search each group and combine results
|
||||
const allResults = [];
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const groupResults = await prowlarr.search(title, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
minSeeders: 0,
|
||||
maxResults: 100,
|
||||
});
|
||||
allResults.push(...groupResults);
|
||||
} catch (error) {
|
||||
logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${allResults.length} results from indexers`);
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Rank results with ebook scoring
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
title,
|
||||
author,
|
||||
preferredFormat,
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
});
|
||||
|
||||
// Convert to unified result type
|
||||
return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({
|
||||
guid: result.guid,
|
||||
title: result.title,
|
||||
size: result.size,
|
||||
seeders: result.seeders,
|
||||
indexer: result.indexer,
|
||||
indexerId: result.indexerId,
|
||||
publishDate: result.publishDate,
|
||||
downloadUrl: result.downloadUrl,
|
||||
infoUrl: result.infoUrl,
|
||||
|
||||
score: result.score,
|
||||
finalScore: result.finalScore,
|
||||
bonusPoints: result.bonusPoints,
|
||||
bonusModifiers: result.bonusModifiers,
|
||||
rank: result.rank,
|
||||
breakdown: result.breakdown,
|
||||
|
||||
source: 'prowlarr',
|
||||
format: result.ebookFormat,
|
||||
protocol: result.protocol,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Component: Select Ebook by ASIN API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Creates an ebook request with a user-selected source (Anna's Archive or indexer)
|
||||
* Routes to appropriate download processor based on source type
|
||||
* Includes approval logic for non-admin users
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.SelectEbook');
|
||||
|
||||
// Statuses that indicate an active/in-progress ebook request
|
||||
const ACTIVE_EBOOK_STATUSES = [
|
||||
'pending',
|
||||
'awaiting_approval',
|
||||
'searching',
|
||||
'downloading',
|
||||
'processing',
|
||||
'downloaded',
|
||||
'available',
|
||||
];
|
||||
|
||||
// Statuses that allow reuse
|
||||
const REUSABLE_STATUSES = ['failed', 'awaiting_search', 'pending'];
|
||||
|
||||
interface SelectedEbook {
|
||||
guid: string;
|
||||
title: string;
|
||||
size: number;
|
||||
seeders: number;
|
||||
indexer: string;
|
||||
indexerId?: number;
|
||||
downloadUrl: string;
|
||||
infoUrl?: string;
|
||||
score: number;
|
||||
finalScore: number;
|
||||
source: 'annas_archive' | 'prowlarr';
|
||||
format?: string;
|
||||
md5?: string;
|
||||
downloadUrls?: string[];
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/audiobooks/[asin]/select-ebook
|
||||
* Select and download an ebook from interactive search results
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { asin } = await params;
|
||||
const body = await request.json();
|
||||
const selectedEbook = body.ebook as SelectedEbook;
|
||||
|
||||
if (!asin || asin.length !== 10) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Valid ASIN is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedEbook) {
|
||||
return NextResponse.json({ error: 'No ebook selected' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!selectedEbook.source) {
|
||||
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// First, fetch audiobook data from Audible (works for books imported outside RMAB)
|
||||
const audibleService = getAudibleService();
|
||||
let audibleData = null;
|
||||
try {
|
||||
audibleData = await audibleService.getAudiobookDetails(asin);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audible data for ASIN ${asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
if (!audibleData) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found on Audible' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check Plex availability using Audible metadata
|
||||
const plexMatch = await findPlexMatch({
|
||||
asin,
|
||||
title: audibleData.title,
|
||||
author: audibleData.author,
|
||||
});
|
||||
|
||||
// Find or create audiobook record
|
||||
let audiobook = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
|
||||
// Check for available request if audiobook exists in database
|
||||
let availableRequest = null;
|
||||
if (audiobook) {
|
||||
availableRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'audiobook',
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isAvailable = !!availableRequest || !!plexMatch;
|
||||
|
||||
if (!isAvailable) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook must be available in your library before requesting an ebook' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// If audiobook doesn't exist in database but is in Plex, create it
|
||||
if (!audiobook) {
|
||||
logger.info(`Creating audiobook record for "${audibleData.title}" (imported outside RMAB)`);
|
||||
|
||||
// Extract year from release date
|
||||
let year: number | undefined;
|
||||
if (audibleData.releaseDate) {
|
||||
try {
|
||||
const releaseYear = new Date(audibleData.releaseDate).getFullYear();
|
||||
if (!isNaN(releaseYear)) {
|
||||
year = releaseYear;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
audiobook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: audibleData.title,
|
||||
author: audibleData.author,
|
||||
narrator: audibleData.narrator,
|
||||
description: audibleData.description,
|
||||
coverArtUrl: audibleData.coverArtUrl,
|
||||
year,
|
||||
series: audibleData.series,
|
||||
seriesPart: audibleData.seriesPart,
|
||||
status: 'available',
|
||||
},
|
||||
});
|
||||
logger.info(`Created audiobook ${audiobook.id} for "${audibleData.title}"`);
|
||||
}
|
||||
|
||||
// Check for existing ebook request
|
||||
let ebookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Handle existing ebook request
|
||||
if (ebookRequest) {
|
||||
if (ACTIVE_EBOOK_STATUSES.includes(ebookRequest.status) &&
|
||||
!REUSABLE_STATUSES.includes(ebookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${ebookRequest.status})`,
|
||||
existingRequestId: ebookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if approval is needed for non-admin users
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let needsApproval = false;
|
||||
|
||||
if (user.role === 'admin') {
|
||||
needsApproval = false;
|
||||
} else {
|
||||
if (user.autoApproveRequests === true) {
|
||||
needsApproval = false;
|
||||
} else if (user.autoApproveRequests === false) {
|
||||
needsApproval = true;
|
||||
} else {
|
||||
const globalConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'auto_approve_requests' },
|
||||
});
|
||||
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
|
||||
needsApproval = !globalAutoApprove;
|
||||
}
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
if (needsApproval) {
|
||||
// Create or update ebook request with awaiting_approval status
|
||||
if (ebookRequest && REUSABLE_STATUSES.includes(ebookRequest.status)) {
|
||||
ebookRequest = await prisma.request.update({
|
||||
where: { id: ebookRequest.id },
|
||||
data: {
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
selectedTorrent: selectedEbook as any, // Store selected ebook for later
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logger.info(`Reusing ebook request ${ebookRequest.id}, awaiting approval`);
|
||||
} else {
|
||||
ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'awaiting_approval',
|
||||
progress: 0,
|
||||
selectedTorrent: selectedEbook as any,
|
||||
},
|
||||
});
|
||||
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
||||
}
|
||||
|
||||
// Send pending approval notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_pending_approval',
|
||||
ebookRequest.id,
|
||||
`${audiobook.title} (Ebook)`,
|
||||
audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Ebook request submitted for admin approval',
|
||||
requestId: ebookRequest.id,
|
||||
needsApproval: true,
|
||||
}, { status: 201 });
|
||||
} else {
|
||||
// Auto-approved - create or update request and start download
|
||||
if (ebookRequest && REUSABLE_STATUSES.includes(ebookRequest.status)) {
|
||||
ebookRequest = await prisma.request.update({
|
||||
where: { id: ebookRequest.id },
|
||||
data: {
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logger.info(`Reusing existing ebook request ${ebookRequest.id}`);
|
||||
} else {
|
||||
ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
parentRequestId: availableRequest?.id || null,
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||
}
|
||||
|
||||
// Route to appropriate download based on source
|
||||
if (selectedEbook.source === 'annas_archive') {
|
||||
await handleAnnasArchiveDownload(
|
||||
ebookRequest.id,
|
||||
audiobook,
|
||||
selectedEbook,
|
||||
jobQueue
|
||||
);
|
||||
} else {
|
||||
await handleIndexerDownload(
|
||||
ebookRequest.id,
|
||||
audiobook,
|
||||
selectedEbook,
|
||||
jobQueue
|
||||
);
|
||||
}
|
||||
|
||||
// Send approved notification
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_approved',
|
||||
ebookRequest.id,
|
||||
`${audiobook.title} (Ebook)`,
|
||||
audiobook.author,
|
||||
user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`,
|
||||
requestId: ebookRequest.id,
|
||||
needsApproval: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Anna's Archive download (direct HTTP)
|
||||
*/
|
||||
async function handleAnnasArchiveDownload(
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
selectedEbook: SelectedEbook,
|
||||
jobQueue: ReturnType<typeof getJobQueueService>
|
||||
) {
|
||||
const configService = getConfigService();
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
|
||||
logger.info(`Starting Anna's Archive download for "${audiobook.title}"`);
|
||||
logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`);
|
||||
|
||||
// Create download history record
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: "Anna's Archive",
|
||||
torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
|
||||
torrentSizeBytes: null,
|
||||
qualityScore: selectedEbook.score,
|
||||
selected: true,
|
||||
downloadClient: 'direct',
|
||||
downloadStatus: 'queued',
|
||||
},
|
||||
});
|
||||
|
||||
// Store all download URLs for retry purposes
|
||||
if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) {
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistory.id },
|
||||
data: {
|
||||
torrentUrl: JSON.stringify(selectedEbook.downloadUrls),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger direct download job
|
||||
await jobQueue.addStartDirectDownloadJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
selectedEbook.downloadUrl,
|
||||
`${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
|
||||
undefined
|
||||
);
|
||||
|
||||
logger.info(`Queued direct download job for request ${requestId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle indexer download (torrent/NZB)
|
||||
*/
|
||||
async function handleIndexerDownload(
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
selectedEbook: SelectedEbook,
|
||||
jobQueue: ReturnType<typeof getJobQueueService>
|
||||
) {
|
||||
logger.info(`Starting indexer download for "${audiobook.title}"`);
|
||||
logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`);
|
||||
|
||||
const torrentForJob = {
|
||||
guid: selectedEbook.guid,
|
||||
title: selectedEbook.title,
|
||||
size: selectedEbook.size,
|
||||
seeders: selectedEbook.seeders || 0,
|
||||
indexer: selectedEbook.indexer,
|
||||
indexerId: selectedEbook.indexerId,
|
||||
downloadUrl: selectedEbook.downloadUrl,
|
||||
infoUrl: selectedEbook.infoUrl,
|
||||
publishDate: new Date(),
|
||||
score: selectedEbook.score,
|
||||
finalScore: selectedEbook.finalScore,
|
||||
bonusPoints: 0,
|
||||
bonusModifiers: [],
|
||||
rank: 1,
|
||||
breakdown: {
|
||||
formatScore: 0,
|
||||
sizeScore: 0,
|
||||
seederScore: 0,
|
||||
matchScore: 0,
|
||||
totalScore: selectedEbook.score,
|
||||
notes: [],
|
||||
},
|
||||
protocol: selectedEbook.protocol,
|
||||
};
|
||||
|
||||
await jobQueue.addDownloadJob(requestId, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}, torrentForJob as any);
|
||||
|
||||
logger.info(`Queued download job for request ${requestId}`);
|
||||
}
|
||||
@@ -64,13 +64,14 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook, torrent } = RequestWithTorrentSchema.parse(body);
|
||||
|
||||
// First check: Is there an existing request in 'downloaded' or 'available' status?
|
||||
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
|
||||
// This catches the gap where files are organized but Plex hasn't scanned yet
|
||||
const existingActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: {
|
||||
audibleAsin: audiobook.asin,
|
||||
},
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -184,11 +185,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
// Check if user already has an active (non-deleted) audiobook request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
@@ -266,6 +268,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'awaiting_approval',
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
selectedTorrent: torrent as any, // Store the selected torrent for later
|
||||
},
|
||||
@@ -307,6 +310,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: 'downloading',
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -136,6 +136,8 @@ async function handler(req: AuthenticatedRequest) {
|
||||
where: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
|
||||
@@ -187,6 +189,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
status: initialStatus,
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
priority: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
* Component: Fetch E-book API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Triggers e-book download for a completed request
|
||||
* Creates an ebook request for a completed audiobook request
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { downloadEbook } from '@/lib/services/ebook-scraper';
|
||||
import { buildAudiobookPath } from '@/lib/utils/file-organizer';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.FetchEbook');
|
||||
@@ -23,132 +20,130 @@ export async function POST(
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { id: parentRequestId } = await params;
|
||||
|
||||
// Check if e-book sidecar is enabled
|
||||
const ebookEnabledConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'ebook_sidecar_enabled' },
|
||||
});
|
||||
// Check which ebook sources are enabled
|
||||
const [annasArchiveConfig, indexerSearchConfig, legacyConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_annas_archive_enabled' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_indexer_search_enabled' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_enabled' } }),
|
||||
]);
|
||||
|
||||
if (ebookEnabledConfig?.value !== 'true') {
|
||||
// Legacy migration: check old key if new keys don't exist
|
||||
const isAnnasArchiveEnabled = annasArchiveConfig?.value === 'true' ||
|
||||
(annasArchiveConfig === null && legacyConfig?.value === 'true');
|
||||
const isIndexerSearchEnabled = indexerSearchConfig?.value === 'true';
|
||||
|
||||
// If no sources are enabled, return error
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-book sidecar feature is not enabled' },
|
||||
{ error: 'E-book sidecar feature is not enabled (no sources configured)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the request with audiobook data
|
||||
const requestRecord = await prisma.request.findUnique({
|
||||
where: { id },
|
||||
// Get the parent request with audiobook data
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
if (!parentRequest) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Request not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if request is in completed state
|
||||
if (!['downloaded', 'available'].includes(requestRecord.status)) {
|
||||
// Check if parent request is in completed state
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot fetch e-book for request in ${requestRecord.status} status` },
|
||||
{ error: `Cannot fetch e-book for request in ${parentRequest.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const audiobook = requestRecord.audiobook;
|
||||
|
||||
// Get configuration
|
||||
const [mediaDirConfig, templateConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'audiobook_path_template' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
|
||||
]);
|
||||
|
||||
const mediaDir = mediaDirConfig?.value || '/media/audiobooks';
|
||||
const template = templateConfig?.value || '{author}/{title} {asin}';
|
||||
const preferredFormat = formatConfig?.value || 'epub';
|
||||
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
|
||||
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
|
||||
|
||||
// Fetch year from audible cache if ASIN is available
|
||||
let year: number | undefined;
|
||||
if (audiobook.audibleAsin) {
|
||||
const audibleCache = await prisma.audibleCache.findUnique({
|
||||
where: { asin: audiobook.audibleAsin },
|
||||
select: { releaseDate: true },
|
||||
});
|
||||
if (audibleCache?.releaseDate) {
|
||||
year = new Date(audibleCache.releaseDate).getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
// Build target path using centralized function
|
||||
const targetPath = buildAudiobookPath(
|
||||
mediaDir,
|
||||
template,
|
||||
{
|
||||
author: audiobook.author,
|
||||
title: audiobook.title,
|
||||
narrator: audiobook.narrator || undefined,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
year,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('Fetch e-book request', {
|
||||
requestId: id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
targetPath,
|
||||
format: preferredFormat,
|
||||
baseUrl,
|
||||
flaresolverr: flaresolverrUrl || 'none'
|
||||
// Check if an ebook request already exists for this parent
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if target directory exists
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
logger.debug(`Target directory not found: ${targetPath}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook directory not found. Was the audiobook properly organized?' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (existingEbookRequest) {
|
||||
// Check status - if failed/pending, we can retry
|
||||
if (['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
// Reset and retry
|
||||
await prisma.request.update({
|
||||
where: { id: existingEbookRequest.id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Download e-book
|
||||
const result = await downloadEbook(
|
||||
audiobook.audibleAsin || '',
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
targetPath,
|
||||
preferredFormat,
|
||||
baseUrl,
|
||||
undefined, // No logger in API context
|
||||
flaresolverrUrl
|
||||
);
|
||||
// Trigger search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(existingEbookRequest.id, {
|
||||
id: parentRequest.audiobook.id,
|
||||
title: parentRequest.audiobook.title,
|
||||
author: parentRequest.audiobook.author,
|
||||
asin: parentRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'} for "${audiobook.title}"`);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'}`,
|
||||
format: result.format,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`E-book download failed for "${audiobook.title}"`, { error: result.error });
|
||||
logger.info(`Retrying ebook request ${existingEbookRequest.id} for "${parentRequest.audiobook.title}"`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'E-book search retried',
|
||||
requestId: existingEbookRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Already exists and not in a retryable state
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: result.error || 'E-book download failed',
|
||||
message: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
requestId: existingEbookRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new ebook request
|
||||
const ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: parentRequest.userId,
|
||||
audiobookId: parentRequest.audiobookId,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created ebook request ${ebookRequest.id} for "${parentRequest.audiobook.title}"`);
|
||||
|
||||
// Trigger ebook search job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(ebookRequest.id, {
|
||||
id: parentRequest.audiobook.id,
|
||||
title: parentRequest.audiobook.title,
|
||||
author: parentRequest.audiobook.author,
|
||||
asin: parentRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'E-book request created and search started',
|
||||
requestId: ebookRequest.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Component: Interactive Search Ebook API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Searches for ebooks from multiple sources (Anna's Archive + Indexers)
|
||||
* Returns combined results for user selection in interactive modal
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
getSlowDownloadLinks,
|
||||
} from '@/lib/services/ebook-scraper';
|
||||
|
||||
const logger = RMABLogger.create('API.InteractiveSearchEbook');
|
||||
|
||||
// Unified result type for frontend
|
||||
export interface EbookSearchResult {
|
||||
// Common fields (match RankedTorrent shape for UI compatibility)
|
||||
guid: string;
|
||||
title: string;
|
||||
size: number;
|
||||
seeders?: number;
|
||||
indexer: string;
|
||||
indexerId?: number;
|
||||
publishDate: Date;
|
||||
downloadUrl: string;
|
||||
infoUrl?: string;
|
||||
protocol?: string; // 'torrent' or 'usenet' - determines download client
|
||||
|
||||
// Ranking fields
|
||||
score: number;
|
||||
finalScore: number;
|
||||
bonusPoints: number;
|
||||
bonusModifiers: Array<{ type: string; value: number; points: number; reason: string }>;
|
||||
rank: number;
|
||||
breakdown: {
|
||||
formatScore: number;
|
||||
sizeScore: number;
|
||||
seederScore: number;
|
||||
matchScore: number;
|
||||
totalScore: number;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
// Ebook-specific fields
|
||||
source: 'annas_archive' | 'prowlarr';
|
||||
format?: string;
|
||||
md5?: string;
|
||||
downloadUrls?: string[];
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id: parentRequestId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const customTitle = body.customTitle as string | undefined;
|
||||
|
||||
// Get the parent audiobook request
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!parentRequest) {
|
||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parentRequest.type !== 'audiobook') {
|
||||
return NextResponse.json({ error: 'Can only search ebooks for audiobook requests' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot search ebooks for request in ${parentRequest.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing non-retryable ebook request
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEbookRequest && !['failed', 'awaiting_search'].includes(existingEbookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${existingEbookRequest.status})`,
|
||||
existingRequestId: existingEbookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get ebook configuration
|
||||
const configService = getConfigService();
|
||||
const [annasArchiveEnabled, indexerSearchEnabled, preferredFormat, baseUrl, flaresolverrUrl] = await Promise.all([
|
||||
configService.get('ebook_annas_archive_enabled'),
|
||||
configService.get('ebook_indexer_search_enabled'),
|
||||
configService.get('ebook_sidecar_preferred_format'),
|
||||
configService.get('ebook_sidecar_base_url'),
|
||||
configService.get('ebook_sidecar_flaresolverr_url'),
|
||||
]);
|
||||
|
||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
|
||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||
const format = preferredFormat || 'epub';
|
||||
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
|
||||
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const audiobook = parentRequest.audiobook;
|
||||
const searchTitle = customTitle || audiobook.title;
|
||||
|
||||
logger.info(`Interactive ebook search for "${searchTitle}" by ${audiobook.author}`);
|
||||
logger.info(`Sources: Anna's Archive=${isAnnasArchiveEnabled}, Indexer=${isIndexerSearchEnabled}`);
|
||||
|
||||
// Search both sources in parallel
|
||||
const searchPromises: Promise<EbookSearchResult[] | null>[] = [];
|
||||
|
||||
if (isAnnasArchiveEnabled) {
|
||||
searchPromises.push(
|
||||
searchAnnasArchiveForInteractive(
|
||||
audiobook.audibleAsin || undefined,
|
||||
searchTitle,
|
||||
audiobook.author,
|
||||
format,
|
||||
annasBaseUrl,
|
||||
flaresolverrUrl || undefined
|
||||
).catch((err) => {
|
||||
logger.error(`Anna's Archive search failed: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (isIndexerSearchEnabled) {
|
||||
searchPromises.push(
|
||||
searchIndexersForInteractive(
|
||||
searchTitle,
|
||||
audiobook.author,
|
||||
format
|
||||
).catch((err) => {
|
||||
logger.error(`Indexer search failed: ${err.message}`);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const searchResults = await Promise.all(searchPromises);
|
||||
|
||||
// Combine results: Anna's Archive first (if found), then ranked indexer results
|
||||
const combinedResults: EbookSearchResult[] = [];
|
||||
let rank = 1;
|
||||
|
||||
// Add Anna's Archive result first (if enabled and found)
|
||||
if (isAnnasArchiveEnabled && searchResults[0]) {
|
||||
const annasResults = searchResults[0];
|
||||
for (const result of annasResults) {
|
||||
combinedResults.push({ ...result, rank: rank++ });
|
||||
}
|
||||
}
|
||||
|
||||
// Add indexer results (already ranked)
|
||||
const indexerResultsIndex = isAnnasArchiveEnabled ? 1 : 0;
|
||||
if (isIndexerSearchEnabled && searchResults[indexerResultsIndex]) {
|
||||
const indexerResults = searchResults[indexerResultsIndex];
|
||||
for (const result of indexerResults) {
|
||||
combinedResults.push({ ...result, rank: rank++ });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${combinedResults.length} total ebook results`);
|
||||
|
||||
return NextResponse.json({
|
||||
results: combinedResults,
|
||||
searchTitle,
|
||||
preferredFormat: format,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Anna's Archive and return normalized results
|
||||
*/
|
||||
async function searchAnnasArchiveForInteractive(
|
||||
asin: string | undefined,
|
||||
title: string,
|
||||
author: string,
|
||||
preferredFormat: string,
|
||||
baseUrl: string,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<EbookSearchResult[]> {
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
|
||||
// Try ASIN search first
|
||||
if (asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
|
||||
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
if (md5) {
|
||||
searchMethod = 'asin';
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to title search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title: "${title}"`);
|
||||
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl);
|
||||
if (md5) {
|
||||
logger.info(`Found via title: ${md5}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!md5) {
|
||||
logger.info('No results from Anna\'s Archive');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get download links
|
||||
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, undefined, flaresolverrUrl);
|
||||
|
||||
if (slowLinks.length === 0) {
|
||||
logger.warn(`Found MD5 ${md5} but no download links available`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return as normalized result - always score 100 for Anna's Archive
|
||||
const score = 100;
|
||||
|
||||
return [{
|
||||
guid: `annas-archive-${md5}`,
|
||||
title: `${title} - ${author}`,
|
||||
size: 0, // Unknown until download
|
||||
seeders: 999, // N/A for direct download, use high number for display
|
||||
indexer: "Anna's Archive",
|
||||
publishDate: new Date(),
|
||||
downloadUrl: slowLinks[0],
|
||||
infoUrl: `${baseUrl}/md5/${md5}`,
|
||||
|
||||
score,
|
||||
finalScore: score,
|
||||
bonusPoints: 0,
|
||||
bonusModifiers: [],
|
||||
rank: 1,
|
||||
breakdown: {
|
||||
formatScore: 10,
|
||||
sizeScore: 15,
|
||||
seederScore: 15,
|
||||
matchScore: 60,
|
||||
totalScore: score,
|
||||
notes: [searchMethod === 'asin' ? 'ASIN match' : 'Title/Author match', "Anna's Archive"],
|
||||
},
|
||||
|
||||
source: 'annas_archive',
|
||||
format: preferredFormat,
|
||||
md5,
|
||||
downloadUrls: slowLinks,
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexers and return ranked results
|
||||
*/
|
||||
async function searchIndexersForInteractive(
|
||||
title: string,
|
||||
author: string,
|
||||
preferredFormat: string
|
||||
): Promise<EbookSearchResult[]> {
|
||||
const configService = getConfigService();
|
||||
|
||||
// Get indexer configuration
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
if (!indexersConfigStr) {
|
||||
logger.warn('No indexers configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
if (indexersConfig.length === 0) {
|
||||
logger.warn('No indexers enabled');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build indexer priorities map
|
||||
const indexerPriorities = new Map<number, number>(
|
||||
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
|
||||
);
|
||||
|
||||
// Get flag configurations
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Group indexers by ebook categories
|
||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
|
||||
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
|
||||
// Search each group and combine results
|
||||
const allResults = [];
|
||||
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const groupResults = await prowlarr.search(title, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
minSeeders: 0,
|
||||
maxResults: 100,
|
||||
});
|
||||
allResults.push(...groupResults);
|
||||
} catch (error) {
|
||||
logger.error(`Group search failed: ${error instanceof Error ? error.message : 'Unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${allResults.length} results from indexers`);
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Rank results with ebook scoring
|
||||
// Use requireAuthor=false for interactive mode (let user decide)
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
title,
|
||||
author,
|
||||
preferredFormat,
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: false,
|
||||
});
|
||||
|
||||
// Log ranking debug info (same format as search-ebook.processor.ts)
|
||||
if (rankedResults.length > 0) {
|
||||
const top3 = rankedResults.slice(0, 3);
|
||||
logger.info(`==================== EBOOK INTERACTIVE SEARCH DEBUG ====================`);
|
||||
logger.info(`Requested Title: "${title}"`);
|
||||
logger.info(`Requested Author: "${author}"`);
|
||||
logger.info(`Preferred Format: ${preferredFormat}`);
|
||||
logger.info(`Top ${top3.length} results (out of ${rankedResults.length} total):`);
|
||||
logger.info(`--------------------------------------------------------------`);
|
||||
for (let i = 0; i < top3.length; i++) {
|
||||
const result = top3[i];
|
||||
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
|
||||
|
||||
logger.info(`${i + 1}. "${result.title}"`);
|
||||
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
|
||||
logger.info(` Format: ${result.ebookFormat || 'unknown'}`);
|
||||
logger.info(``);
|
||||
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
|
||||
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
|
||||
logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`);
|
||||
logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`);
|
||||
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
|
||||
logger.info(``);
|
||||
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
|
||||
if (result.bonusModifiers.length > 0) {
|
||||
for (const mod of result.bonusModifiers) {
|
||||
logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
logger.info(``);
|
||||
logger.info(` Final Score: ${result.finalScore.toFixed(1)}`);
|
||||
if (result.breakdown.notes.length > 0) {
|
||||
logger.info(` Notes: ${result.breakdown.notes.join(', ')}`);
|
||||
}
|
||||
if (i < top3.length - 1) {
|
||||
logger.info(`--------------------------------------------------------------`);
|
||||
}
|
||||
}
|
||||
logger.info(`==============================================================`);
|
||||
}
|
||||
|
||||
// Convert to unified result type
|
||||
return rankedResults.map((result: RankedEbookTorrent): EbookSearchResult => ({
|
||||
guid: result.guid,
|
||||
title: result.title,
|
||||
size: result.size,
|
||||
seeders: result.seeders,
|
||||
indexer: result.indexer,
|
||||
indexerId: result.indexerId,
|
||||
publishDate: result.publishDate,
|
||||
downloadUrl: result.downloadUrl,
|
||||
infoUrl: result.infoUrl,
|
||||
|
||||
score: result.score,
|
||||
finalScore: result.finalScore,
|
||||
bonusPoints: result.bonusPoints,
|
||||
bonusModifiers: result.bonusModifiers,
|
||||
rank: result.rank,
|
||||
breakdown: result.breakdown,
|
||||
|
||||
source: 'prowlarr',
|
||||
format: result.ebookFormat,
|
||||
protocol: result.protocol,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Component: Select Ebook API
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Creates an ebook request with a user-selected source (Anna's Archive or indexer)
|
||||
* Routes to appropriate download processor based on source type
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.SelectEbook');
|
||||
|
||||
interface SelectedEbook {
|
||||
guid: string;
|
||||
title: string;
|
||||
size: number;
|
||||
seeders: number;
|
||||
indexer: string;
|
||||
indexerId?: number;
|
||||
downloadUrl: string;
|
||||
infoUrl?: string;
|
||||
score: number;
|
||||
finalScore: number;
|
||||
source: 'annas_archive' | 'prowlarr';
|
||||
format?: string;
|
||||
md5?: string;
|
||||
downloadUrls?: string[];
|
||||
protocol?: string; // 'torrent' or 'usenet' - determines download client
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id: parentRequestId } = await params;
|
||||
const body = await request.json();
|
||||
const selectedEbook = body.ebook as SelectedEbook;
|
||||
|
||||
if (!selectedEbook) {
|
||||
return NextResponse.json({ error: 'No ebook selected' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!selectedEbook.source) {
|
||||
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get the parent audiobook request
|
||||
const parentRequest = await prisma.request.findUnique({
|
||||
where: { id: parentRequestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!parentRequest) {
|
||||
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (parentRequest.type !== 'audiobook') {
|
||||
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['downloaded', 'available'].includes(parentRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Cannot select ebook for request in ${parentRequest.status} status` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing ebook request
|
||||
let ebookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
|
||||
return NextResponse.json({
|
||||
error: `E-book request already exists (status: ${ebookRequest.status})`,
|
||||
existingRequestId: ebookRequest.id,
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Create or update ebook request
|
||||
if (ebookRequest) {
|
||||
// Reset existing failed/pending request
|
||||
ebookRequest = await prisma.request.update({
|
||||
where: { id: ebookRequest.id },
|
||||
data: {
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
logger.info(`Reusing existing ebook request ${ebookRequest.id}`);
|
||||
} else {
|
||||
// Create new ebook request
|
||||
ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: parentRequest.userId,
|
||||
audiobookId: parentRequest.audiobookId,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
status: 'searching',
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||
}
|
||||
|
||||
const audiobook = parentRequest.audiobook;
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Route to appropriate download based on source
|
||||
if (selectedEbook.source === 'annas_archive') {
|
||||
// Anna's Archive: Direct HTTP download
|
||||
await handleAnnasArchiveDownload(
|
||||
ebookRequest.id,
|
||||
audiobook,
|
||||
selectedEbook,
|
||||
jobQueue
|
||||
);
|
||||
} else {
|
||||
// Indexer: Torrent/NZB download
|
||||
await handleIndexerDownload(
|
||||
ebookRequest.id,
|
||||
audiobook,
|
||||
selectedEbook,
|
||||
jobQueue
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `E-book download started from ${selectedEbook.source === 'annas_archive' ? "Anna's Archive" : selectedEbook.indexer}`,
|
||||
requestId: ebookRequest.id,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Anna's Archive download (direct HTTP)
|
||||
*/
|
||||
async function handleAnnasArchiveDownload(
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
selectedEbook: SelectedEbook,
|
||||
jobQueue: ReturnType<typeof getJobQueueService>
|
||||
) {
|
||||
const configService = getConfigService();
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
|
||||
logger.info(`Starting Anna's Archive download for "${audiobook.title}"`);
|
||||
logger.info(`MD5: ${selectedEbook.md5}, Format: ${selectedEbook.format || preferredFormat}`);
|
||||
|
||||
// Create download history record
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: "Anna's Archive",
|
||||
torrentName: `${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
|
||||
torrentSizeBytes: null, // Unknown until download starts
|
||||
qualityScore: selectedEbook.score,
|
||||
selected: true,
|
||||
downloadClient: 'direct',
|
||||
downloadStatus: 'queued',
|
||||
},
|
||||
});
|
||||
|
||||
// Store all download URLs for retry purposes
|
||||
if (selectedEbook.downloadUrls && selectedEbook.downloadUrls.length > 0) {
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistory.id },
|
||||
data: {
|
||||
torrentUrl: JSON.stringify(selectedEbook.downloadUrls),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger direct download job
|
||||
await jobQueue.addStartDirectDownloadJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
selectedEbook.downloadUrl,
|
||||
`${audiobook.title} - ${audiobook.author}.${selectedEbook.format || preferredFormat}`,
|
||||
undefined // Size unknown
|
||||
);
|
||||
|
||||
logger.info(`Queued direct download job for request ${requestId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle indexer download (torrent/NZB)
|
||||
*/
|
||||
async function handleIndexerDownload(
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string },
|
||||
selectedEbook: SelectedEbook,
|
||||
jobQueue: ReturnType<typeof getJobQueueService>
|
||||
) {
|
||||
logger.info(`Starting indexer download for "${audiobook.title}"`);
|
||||
logger.info(`Torrent: "${selectedEbook.title}", Indexer: ${selectedEbook.indexer}`);
|
||||
|
||||
// Convert to RankedTorrent shape expected by download job
|
||||
// Note: format is omitted as ebook formats (epub, pdf) differ from audiobook formats (M4B, M4A, MP3)
|
||||
const torrentForJob = {
|
||||
guid: selectedEbook.guid,
|
||||
title: selectedEbook.title,
|
||||
size: selectedEbook.size,
|
||||
seeders: selectedEbook.seeders || 0,
|
||||
indexer: selectedEbook.indexer,
|
||||
indexerId: selectedEbook.indexerId,
|
||||
downloadUrl: selectedEbook.downloadUrl,
|
||||
infoUrl: selectedEbook.infoUrl,
|
||||
publishDate: new Date(),
|
||||
score: selectedEbook.score,
|
||||
finalScore: selectedEbook.finalScore,
|
||||
bonusPoints: 0,
|
||||
bonusModifiers: [],
|
||||
rank: 1,
|
||||
breakdown: {
|
||||
formatScore: 0,
|
||||
sizeScore: 0,
|
||||
seederScore: 0,
|
||||
matchScore: 0,
|
||||
totalScore: selectedEbook.score,
|
||||
notes: [],
|
||||
},
|
||||
protocol: selectedEbook.protocol, // Pass through protocol for torrent vs usenet routing
|
||||
};
|
||||
|
||||
// Use the download job (same as audiobooks)
|
||||
await jobQueue.addDownloadJob(requestId, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}, torrentForJob as any); // Cast to any since ebook torrents don't have audiobook format field
|
||||
|
||||
logger.info(`Queued download job for request ${requestId}`);
|
||||
}
|
||||
@@ -45,13 +45,14 @@ export async function POST(request: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { audiobook } = CreateRequestSchema.parse(body);
|
||||
|
||||
// First check: Is there an existing request in 'downloaded' or 'available' status?
|
||||
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status?
|
||||
// This catches the gap where files are organized but Plex hasn't scanned yet
|
||||
const existingActiveRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobook: {
|
||||
audibleAsin: audiobook.asin,
|
||||
},
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
status: { in: ['downloaded', 'available'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -165,11 +166,12 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
// Check if user already has an active (non-deleted) audiobook request for this audiobook
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
},
|
||||
});
|
||||
@@ -257,6 +259,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: req.user.id,
|
||||
audiobookId: audiobookRecord.id,
|
||||
status: initialStatus,
|
||||
type: 'audiobook', // Explicit type for user-created requests
|
||||
progress: 0,
|
||||
},
|
||||
include: {
|
||||
@@ -353,6 +356,7 @@ export async function GET(request: NextRequest) {
|
||||
const status = searchParams.get('status');
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10);
|
||||
const myOnly = searchParams.get('myOnly') === 'true';
|
||||
const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
|
||||
// Build query
|
||||
@@ -362,6 +366,10 @@ export async function GET(request: NextRequest) {
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
// Filter by type if specified (otherwise returns all types)
|
||||
if (type && ['audiobook', 'ebook'].includes(type)) {
|
||||
where.type = type;
|
||||
}
|
||||
// Only show active (non-deleted) requests
|
||||
where.deletedAt = null;
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ interface SelectedIndexer {
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
audiobookCategories: number[]; // Categories for audiobook searches
|
||||
ebookCategories: number[]; // Categories for ebook searches
|
||||
}
|
||||
|
||||
export function ProwlarrStep({
|
||||
|
||||
@@ -16,12 +16,15 @@ import {
|
||||
interface CategoryTreeViewProps {
|
||||
selectedCategories: number[];
|
||||
onChange: (categories: number[]) => void;
|
||||
defaultCategories?: number[]; // Categories to show "Default" badge for (e.g., [3030] for audiobook, [7020] for ebook)
|
||||
}
|
||||
|
||||
export function CategoryTreeView({
|
||||
selectedCategories,
|
||||
onChange,
|
||||
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
|
||||
}: CategoryTreeViewProps) {
|
||||
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
|
||||
const handleParentToggle = (parentId: number) => {
|
||||
const childIds = getChildIds(parentId);
|
||||
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
|
||||
@@ -75,7 +78,7 @@ export function CategoryTreeView({
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{category.id}]
|
||||
</span>
|
||||
{category.id === 3030 && (
|
||||
{isDefaultCategory(category.id) && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
|
||||
Default
|
||||
</span>
|
||||
@@ -109,7 +112,7 @@ export function CategoryTreeView({
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
|
||||
[{child.id}]
|
||||
</span>
|
||||
{child.id === 3030 && (
|
||||
{isDefaultCategory(child.id) && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
|
||||
Default
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Component: Indexer Configuration Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Supports separate category configurations for AudioBook and EBook searches
|
||||
* via tabbed interface in the Categories section.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -10,7 +13,9 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { CategoryTreeView } from './CategoryTreeView';
|
||||
import { DEFAULT_CATEGORIES } from '@/lib/utils/torrent-categories';
|
||||
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
|
||||
|
||||
type CategoryTab = 'audiobook' | 'ebook';
|
||||
|
||||
interface IndexerConfigModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -27,7 +32,8 @@ interface IndexerConfigModalProps {
|
||||
seedingTimeMinutes?: number;
|
||||
removeAfterProcessing?: boolean;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
audiobookCategories: number[];
|
||||
ebookCategories: number[];
|
||||
};
|
||||
onSave: (config: {
|
||||
id: number;
|
||||
@@ -37,7 +43,8 @@ interface IndexerConfigModalProps {
|
||||
seedingTimeMinutes?: number;
|
||||
removeAfterProcessing?: boolean;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
audiobookCategories: number[];
|
||||
ebookCategories: number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -56,7 +63,8 @@ export function IndexerConfigModal({
|
||||
seedingTimeMinutes: 0,
|
||||
removeAfterProcessing: true, // Default to true for Usenet
|
||||
rssEnabled: indexer.supportsRss,
|
||||
categories: DEFAULT_CATEGORIES, // Default to Audio/Audiobook [3030]
|
||||
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
|
||||
ebookCategories: DEFAULT_EBOOK_CATEGORIES,
|
||||
};
|
||||
|
||||
// Form state
|
||||
@@ -72,15 +80,24 @@ export function IndexerConfigModal({
|
||||
const [rssEnabled, setRssEnabled] = useState(
|
||||
initialConfig?.rssEnabled ?? defaults.rssEnabled
|
||||
);
|
||||
const [selectedCategories, setSelectedCategories] = useState<number[]>(
|
||||
initialConfig?.categories ?? defaults.categories
|
||||
|
||||
// Dual category state
|
||||
const [audiobookCategories, setAudiobookCategories] = useState<number[]>(
|
||||
initialConfig?.audiobookCategories ?? defaults.audiobookCategories
|
||||
);
|
||||
const [ebookCategories, setEbookCategories] = useState<number[]>(
|
||||
initialConfig?.ebookCategories ?? defaults.ebookCategories
|
||||
);
|
||||
|
||||
// Tab state for categories
|
||||
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
|
||||
|
||||
// Validation errors
|
||||
const [errors, setErrors] = useState<{
|
||||
priority?: string;
|
||||
seedingTimeMinutes?: string;
|
||||
categories?: string;
|
||||
audiobookCategories?: string;
|
||||
ebookCategories?: string;
|
||||
}>({});
|
||||
|
||||
// Reset form when modal opens or indexer changes
|
||||
@@ -91,14 +108,17 @@ export function IndexerConfigModal({
|
||||
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
|
||||
setRemoveAfterProcessing(defaults.removeAfterProcessing);
|
||||
setRssEnabled(defaults.rssEnabled);
|
||||
setSelectedCategories(defaults.categories);
|
||||
setAudiobookCategories(defaults.audiobookCategories);
|
||||
setEbookCategories(defaults.ebookCategories);
|
||||
} else {
|
||||
setPriority(initialConfig?.priority ?? defaults.priority);
|
||||
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
|
||||
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
|
||||
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
|
||||
setSelectedCategories(initialConfig?.categories ?? defaults.categories);
|
||||
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
|
||||
setEbookCategories(initialConfig?.ebookCategories ?? defaults.ebookCategories);
|
||||
}
|
||||
setActiveTab('audiobook');
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen, mode, indexer.id]);
|
||||
@@ -114,8 +134,12 @@ export function IndexerConfigModal({
|
||||
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
|
||||
}
|
||||
|
||||
if (selectedCategories.length === 0) {
|
||||
newErrors.categories = 'At least one category must be selected';
|
||||
if (audiobookCategories.length === 0) {
|
||||
newErrors.audiobookCategories = 'At least one audiobook category must be selected';
|
||||
}
|
||||
|
||||
if (ebookCategories.length === 0) {
|
||||
newErrors.ebookCategories = 'At least one ebook category must be selected';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -124,6 +148,12 @@ export function IndexerConfigModal({
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validate()) {
|
||||
// If there's a category error, switch to the relevant tab
|
||||
if (errors.audiobookCategories && activeTab !== 'audiobook') {
|
||||
setActiveTab('audiobook');
|
||||
} else if (errors.ebookCategories && activeTab !== 'ebook') {
|
||||
setActiveTab('ebook');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,7 +163,8 @@ export function IndexerConfigModal({
|
||||
protocol: indexer.protocol,
|
||||
priority,
|
||||
rssEnabled: indexer.supportsRss ? rssEnabled : false,
|
||||
categories: selectedCategories,
|
||||
audiobookCategories,
|
||||
ebookCategories,
|
||||
};
|
||||
|
||||
// Add protocol-specific fields
|
||||
@@ -168,6 +199,12 @@ export function IndexerConfigModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current categories based on active tab
|
||||
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
|
||||
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
|
||||
const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories;
|
||||
const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -287,23 +324,62 @@ export function IndexerConfigModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{/* Categories with Tabs */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Categories
|
||||
</label>
|
||||
<div className="max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('audiobook')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'audiobook'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
AudioBook
|
||||
{errors.audiobookCategories && (
|
||||
<span className="ml-2 text-red-500">!</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('ebook')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'ebook'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
EBook
|
||||
{errors.ebookCategories && (
|
||||
<span className="ml-2 text-red-500">!</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="max-h-72 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<CategoryTreeView
|
||||
selectedCategories={selectedCategories}
|
||||
onChange={setSelectedCategories}
|
||||
selectedCategories={currentCategories}
|
||||
onChange={setCurrentCategories}
|
||||
defaultCategories={defaultForTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Select categories to search on this indexer. Parent selection locks all children as selected.
|
||||
{activeTab === 'audiobook'
|
||||
? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]'
|
||||
: 'Categories to search for e-books. Default: Books/EBook [7020]'}
|
||||
</p>
|
||||
{errors.categories && (
|
||||
|
||||
{currentError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.categories}
|
||||
{currentError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,8 @@ interface SavedIndexerConfig {
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
audiobookCategories: number[]; // Categories for audiobook searches
|
||||
ebookCategories: number[]; // Categories for ebook searches
|
||||
}
|
||||
|
||||
interface IndexerManagementProps {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { StatusBadge } from '@/components/requests/StatusBadge';
|
||||
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCreateRequest } from '@/lib/hooks/useRequests';
|
||||
import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
|
||||
@@ -39,12 +39,21 @@ export function AudiobookDetailsModal({
|
||||
const { user } = useAuth();
|
||||
const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
||||
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('Request created successfully!');
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||
const [asinCopied, setAsinCopied] = useState(false);
|
||||
|
||||
// Determine if ebook buttons should be shown
|
||||
const canShowEbookButtons = isAvailable &&
|
||||
ebookStatus?.ebookSourcesEnabled &&
|
||||
!ebookStatus?.hasActiveEbookRequest;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@@ -68,6 +77,7 @@ export function AudiobookDetailsModal({
|
||||
|
||||
try {
|
||||
await createRequest(audiobook);
|
||||
setToastMessage('Request created successfully!');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
@@ -103,6 +113,53 @@ export function AudiobookDetailsModal({
|
||||
onRequestSuccess?.();
|
||||
};
|
||||
|
||||
const handleFetchEbook = async () => {
|
||||
if (!user) {
|
||||
setRequestError('Please log in to request ebooks');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchEbook(asin);
|
||||
revalidateEbookStatus();
|
||||
|
||||
if (result.needsApproval) {
|
||||
setToastMessage('Ebook request submitted for approval!');
|
||||
} else {
|
||||
setToastMessage('Ebook search started!');
|
||||
}
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setRequestError(err instanceof Error ? err.message : 'Failed to request ebook');
|
||||
setTimeout(() => setRequestError(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbook = () => {
|
||||
if (!user) {
|
||||
setRequestError('Please log in to request ebooks');
|
||||
return;
|
||||
}
|
||||
setShowInteractiveSearchEbook(true);
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbookClose = () => {
|
||||
setShowInteractiveSearchEbook(false);
|
||||
revalidateEbookStatus();
|
||||
};
|
||||
|
||||
const handleInteractiveSearchEbookSuccess = () => {
|
||||
revalidateEbookStatus();
|
||||
setToastMessage('Ebook download started!');
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const formatDuration = (minutes?: number) => {
|
||||
if (!minutes) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -419,13 +476,127 @@ export function AudiobookDetailsModal({
|
||||
// Check if book is already available in library or completed status
|
||||
if (isAvailable || requestStatus === 'completed') {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
|
||||
<span className="text-base font-semibold text-green-700 dark:text-green-400">
|
||||
Available in Your Library
|
||||
</span>
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="w-full py-3 px-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-lg text-center">
|
||||
<span className="text-base font-semibold text-green-700 dark:text-green-400">
|
||||
Available in Your Library
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ebook Buttons - Only shown when audiobook is available and ebook sources enabled */}
|
||||
{canShowEbookButtons && user && (
|
||||
<>
|
||||
{/* Grab Ebook Button */}
|
||||
<button
|
||||
onClick={handleFetchEbook}
|
||||
disabled={isFetchingEbook}
|
||||
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
borderColor: '#f16f19',
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
|
||||
}}
|
||||
title="Grab Ebook"
|
||||
aria-label="Grab Ebook"
|
||||
>
|
||||
{isFetchingEbook ? (
|
||||
<div className="animate-spin w-6 h-6 border-2 border-current border-t-transparent rounded-full" style={{ color: '#f16f19' }} />
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
Grab Ebook
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Interactive Search Ebook Button */}
|
||||
<button
|
||||
onClick={handleInteractiveSearchEbook}
|
||||
className="group relative inline-flex items-center justify-center p-3 rounded-lg border-2 transition-colors"
|
||||
style={{
|
||||
borderColor: '#f16f19',
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(241, 111, 25, 0.1)';
|
||||
}}
|
||||
title="Search Ebook Sources"
|
||||
aria-label="Search Ebook Sources"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/* Tooltip */}
|
||||
<span className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
|
||||
Search Ebook Sources
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show ebook request status if one exists */}
|
||||
{ebookStatus?.hasActiveEbookRequest && (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border-2 text-sm font-medium"
|
||||
style={{
|
||||
borderColor: '#f16f19',
|
||||
backgroundColor: 'rgba(241, 111, 25, 0.1)',
|
||||
color: '#f16f19',
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval'
|
||||
? 'Pending Approval'
|
||||
: ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded'
|
||||
? 'Available'
|
||||
: 'In Progress'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,7 +713,7 @@ export function AudiobookDetailsModal({
|
||||
{showToast && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<p className="text-green-800 dark:text-green-200 text-center font-medium">
|
||||
✓ Request created successfully!
|
||||
✓ {toastMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -555,7 +726,7 @@ export function AudiobookDetailsModal({
|
||||
return (
|
||||
<>
|
||||
{createPortal(modalContent, document.body)}
|
||||
{/* Interactive Search Modal - render with higher z-index to appear above details modal */}
|
||||
{/* Interactive Search Modal (Audiobook) - render with higher z-index to appear above details modal */}
|
||||
{showInteractiveSearch && audiobook && createPortal(
|
||||
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
|
||||
<div style={{ pointerEvents: 'auto' }}>
|
||||
@@ -573,6 +744,25 @@ export function AudiobookDetailsModal({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{/* Interactive Search Modal (Ebook) - render with higher z-index to appear above details modal */}
|
||||
{showInteractiveSearchEbook && audiobook && createPortal(
|
||||
<div className="fixed inset-0 z-[60]" style={{ pointerEvents: 'none' }}>
|
||||
<div style={{ pointerEvents: 'auto' }}>
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={showInteractiveSearchEbook}
|
||||
onClose={handleInteractiveSearchEbookClose}
|
||||
onSuccess={handleInteractiveSearchEbookSuccess}
|
||||
asin={asin}
|
||||
audiobook={{
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Component: Interactive Torrent Search Modal
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*
|
||||
* Supports two search modes:
|
||||
* - audiobook: Search for audiobook torrents/NZBs (default)
|
||||
* - ebook: Search for ebooks from Anna's Archive + indexers
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -10,30 +14,43 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
|
||||
import {
|
||||
useInteractiveSearch,
|
||||
useSelectTorrent,
|
||||
useSearchTorrents,
|
||||
useRequestWithTorrent,
|
||||
useInteractiveSearchEbook,
|
||||
useSelectEbook,
|
||||
useInteractiveSearchEbookByAsin,
|
||||
useSelectEbookByAsin,
|
||||
} from '@/lib/hooks/useRequests';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
|
||||
interface InteractiveTorrentSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
requestId?: string; // Optional - only provided when called from existing request
|
||||
asin?: string; // Optional - ASIN for ebook mode when no request exists
|
||||
audiobook: {
|
||||
title: string;
|
||||
author: string;
|
||||
};
|
||||
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
||||
onSuccess?: () => void;
|
||||
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||
}
|
||||
|
||||
export function InteractiveTorrentSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
requestId,
|
||||
asin,
|
||||
audiobook,
|
||||
fullAudiobook,
|
||||
onSuccess,
|
||||
searchMode = 'audiobook',
|
||||
}: InteractiveTorrentSearchModalProps) {
|
||||
// Hooks for existing request flow
|
||||
// Hooks for existing audiobook request flow
|
||||
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
|
||||
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
|
||||
|
||||
@@ -41,17 +58,36 @@ export function InteractiveTorrentSearchModal({
|
||||
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
|
||||
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
|
||||
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]);
|
||||
// Hooks for ebook flow (request ID-based - admin)
|
||||
const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook();
|
||||
const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook();
|
||||
|
||||
// Hooks for ebook flow (ASIN-based - user)
|
||||
const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin();
|
||||
const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin();
|
||||
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
||||
|
||||
// Determine which mode we're in
|
||||
const isEbookMode = searchMode === 'ebook';
|
||||
const hasRequestId = !!requestId;
|
||||
const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook;
|
||||
const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent;
|
||||
const error = hasRequestId
|
||||
? (searchByRequestError || selectTorrentError)
|
||||
: (searchByAudiobookError || requestWithTorrentError);
|
||||
const hasAsin = !!asin;
|
||||
const useAsinMode = isEbookMode && hasAsin && !hasRequestId;
|
||||
|
||||
// Loading/error state based on mode
|
||||
const isSearching = isEbookMode
|
||||
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
|
||||
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
|
||||
const isDownloading = isEbookMode
|
||||
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
|
||||
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||
const error = isEbookMode
|
||||
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
|
||||
: (hasRequestId
|
||||
? (searchByRequestError || selectTorrentError)
|
||||
: (searchByAudiobookError || requestWithTorrentError));
|
||||
|
||||
// Reset search title when modal opens/closes or audiobook changes
|
||||
React.useEffect(() => {
|
||||
@@ -72,14 +108,27 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (hasRequestId) {
|
||||
// Existing flow: search by requestId with optional custom title
|
||||
if (isEbookMode) {
|
||||
// Ebook mode: search Anna's Archive + indexers
|
||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||
if (useAsinMode && asin) {
|
||||
// ASIN-based ebook search (user flow from details modal)
|
||||
data = await searchEbooksByAsin(asin, customTitle);
|
||||
} else if (requestId) {
|
||||
// Request ID-based ebook search (admin flow)
|
||||
data = await searchEbooks(requestId, customTitle);
|
||||
} else {
|
||||
console.error('Ebook search requires either requestId or asin');
|
||||
return;
|
||||
}
|
||||
} else if (hasRequestId) {
|
||||
// Existing audiobook flow: search by requestId with optional custom title
|
||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||
data = await searchByRequestId(requestId, customTitle);
|
||||
} else {
|
||||
// New flow: search by custom title + original author + optional ASIN for size scoring
|
||||
const asin = fullAudiobook?.asin;
|
||||
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
|
||||
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
|
||||
const audiobookAsin = fullAudiobook?.asin;
|
||||
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
|
||||
}
|
||||
setResults(data || []);
|
||||
} catch (err) {
|
||||
@@ -102,11 +151,22 @@ export function InteractiveTorrentSearchModal({
|
||||
if (!confirmTorrent) return;
|
||||
|
||||
try {
|
||||
if (hasRequestId) {
|
||||
// Existing flow: select torrent for existing request
|
||||
if (isEbookMode) {
|
||||
// Ebook flow
|
||||
if (useAsinMode && asin) {
|
||||
// ASIN-based ebook selection (user flow from details modal)
|
||||
await selectEbookByAsin(asin, confirmTorrent);
|
||||
} else if (requestId) {
|
||||
// Request ID-based ebook selection (admin flow)
|
||||
await selectEbook(requestId, confirmTorrent);
|
||||
} else {
|
||||
throw new Error('Request ID or ASIN required for ebook selection');
|
||||
}
|
||||
} else if (hasRequestId) {
|
||||
// Existing audiobook flow: select torrent for existing request
|
||||
await selectTorrent(requestId, confirmTorrent);
|
||||
} else {
|
||||
// New flow: create request with torrent
|
||||
// New audiobook flow: create request with torrent
|
||||
if (!fullAudiobook) {
|
||||
throw new Error('Audiobook data required to create request');
|
||||
}
|
||||
@@ -120,7 +180,7 @@ export function InteractiveTorrentSearchModal({
|
||||
// Request list will auto-refresh via SWR
|
||||
} catch (err) {
|
||||
// Error already handled by hook
|
||||
console.error('Failed to download torrent:', err);
|
||||
console.error('Failed to download:', err);
|
||||
setConfirmTorrent(null);
|
||||
}
|
||||
};
|
||||
@@ -138,14 +198,26 @@ export function InteractiveTorrentSearchModal({
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
};
|
||||
|
||||
// UI text based on mode
|
||||
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent';
|
||||
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title';
|
||||
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
|
||||
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
|
||||
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
|
||||
const resultCountText = (count: number) =>
|
||||
isEbookMode
|
||||
? `Found ${count} ebook${count !== 1 ? 's' : ''}`
|
||||
: `Found ${count} torrent${count !== 1 ? 's' : ''}`;
|
||||
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full">
|
||||
<div className="space-y-4">
|
||||
{/* Search customization - editable for ALL modes */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search Title
|
||||
{searchLabel}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -153,7 +225,7 @@ export function InteractiveTorrentSearchModal({
|
||||
value={searchTitle}
|
||||
onChange={(e) => setSearchTitle(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
placeholder="Enter book title to search..."
|
||||
placeholder={searchPlaceholder}
|
||||
disabled={isSearching}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
||||
/>
|
||||
@@ -180,14 +252,14 @@ export function InteractiveTorrentSearchModal({
|
||||
{isSearching && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
|
||||
<span className="ml-3 text-gray-600 dark:text-gray-400">Searching for torrents...</span>
|
||||
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!isSearching && results.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No torrents/nzbs found</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p>
|
||||
<Button onClick={performSearch} variant="outline" className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
@@ -220,7 +292,7 @@ export function InteractiveTorrentSearchModal({
|
||||
Seeds
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
|
||||
Indexer
|
||||
{isEbookMode ? 'Source' : 'Indexer'}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
|
||||
Action
|
||||
@@ -246,21 +318,30 @@ export function InteractiveTorrentSearchModal({
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 flex-wrap">
|
||||
{/* Anna's Archive badge for ebook mode */}
|
||||
{isEbookMode && result.source === 'annas_archive' && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
|
||||
Anna's Archive
|
||||
</span>
|
||||
)}
|
||||
{result.format && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
|
||||
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
|
||||
{result.format}
|
||||
</span>
|
||||
)}
|
||||
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
{formatSize(result.size)}
|
||||
</span>
|
||||
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
|
||||
{result.seeders} seeds
|
||||
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
|
||||
</span>
|
||||
{/* Hide seeds badge for Anna's Archive results */}
|
||||
{!(isEbookMode && result.source === 'annas_archive') && (
|
||||
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
|
||||
{result.seeders} seeds
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
|
||||
{formatSize(result.size)}
|
||||
{result.size > 0 ? formatSize(result.size) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm">
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
|
||||
@@ -271,15 +352,23 @@ export function InteractiveTorrentSearchModal({
|
||||
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{result.seeders}
|
||||
</span>
|
||||
{isEbookMode && result.source === 'annas_archive' ? (
|
||||
<span className="text-gray-400">N/A</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{result.seeders}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
|
||||
{result.indexer}
|
||||
{isEbookMode && result.source === 'annas_archive' ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : (
|
||||
result.indexer
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
|
||||
<Button
|
||||
@@ -303,7 +392,7 @@ export function InteractiveTorrentSearchModal({
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Found {results.length} torrent{results.length !== 1 ? 's' : ''}
|
||||
{resultCountText(results.length)}
|
||||
</p>
|
||||
<Button onClick={performSearch} variant="outline" size="sm">
|
||||
Refresh Results
|
||||
@@ -318,7 +407,7 @@ export function InteractiveTorrentSearchModal({
|
||||
isOpen={!!confirmTorrent}
|
||||
onClose={() => setConfirmTorrent(null)}
|
||||
onConfirm={handleConfirmDownload}
|
||||
title="Download Torrent"
|
||||
title={confirmTitle}
|
||||
message={`Download "${confirmTorrent?.title}"?`}
|
||||
confirmText="Download"
|
||||
isLoading={isDownloading}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
|
||||
interface RequestCardProps {
|
||||
request: {
|
||||
id: string;
|
||||
type?: 'audiobook' | 'ebook';
|
||||
status: string;
|
||||
progress: number;
|
||||
errorMessage?: string;
|
||||
@@ -38,10 +39,14 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
|
||||
|
||||
const requestType = request.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
// Ebook requests don't support interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
@@ -100,19 +105,30 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
{isEbook ? (
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -130,9 +146,20 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Badge and Type Badge */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StatusBadge status={request.status} progress={request.progress} />
|
||||
{isEbook && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
Ebook
|
||||
</span>
|
||||
)}
|
||||
{isActive && request.progress > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="animate-pulse w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
|
||||
@@ -927,7 +927,7 @@ export async function isInLibrary(
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if book has already been requested
|
||||
* Check if book has already been requested (audiobook request)
|
||||
* @param userId - User ID
|
||||
* @param asin - Audible ASIN
|
||||
* @returns true if book is already requested
|
||||
@@ -939,6 +939,8 @@ export async function isAlreadyRequested(
|
||||
const request = await prisma.request.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
|
||||
deletedAt: null, // Only check active requests
|
||||
audiobook: {
|
||||
audibleAsin: asin,
|
||||
},
|
||||
|
||||
@@ -397,3 +397,247 @@ export function useRequestWithTorrent() {
|
||||
|
||||
return { requestWithTorrent, isLoading, error };
|
||||
}
|
||||
|
||||
export function useInteractiveSearchEbook() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const searchEbooks = async (requestId: string, customTitle?: string) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search-ebook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to search for ebooks');
|
||||
}
|
||||
|
||||
return data.results || [];
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { searchEbooks, isLoading, error };
|
||||
}
|
||||
|
||||
export function useSelectEbook() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectEbook = async (requestId: string, ebook: any) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/requests/${requestId}/select-ebook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ebook }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to download ebook');
|
||||
}
|
||||
|
||||
// Revalidate requests
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { selectEbook, isLoading, error };
|
||||
}
|
||||
|
||||
// ==================== ASIN-based Ebook Hooks ====================
|
||||
// These hooks are used for requesting ebooks from the audiobook details modal
|
||||
// where we only have an ASIN, not an existing request ID
|
||||
|
||||
export interface EbookStatus {
|
||||
ebookSourcesEnabled: boolean;
|
||||
hasActiveEbookRequest: boolean;
|
||||
existingEbookStatus: string | null;
|
||||
existingEbookRequestId: string | null;
|
||||
}
|
||||
|
||||
export function useEbookStatus(asin: string | null) {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken && asin ? `/api/audiobooks/${asin}/ebook-status` : null;
|
||||
|
||||
const { data, error, isLoading, mutate: revalidate } = useSWR<EbookStatus>(
|
||||
endpoint,
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 10000, // Refresh every 10 seconds
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
ebookStatus: data || null,
|
||||
isLoading,
|
||||
error,
|
||||
revalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFetchEbookByAsin() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEbook = async (asin: string) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/audiobooks/${asin}/fetch-ebook`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to request ebook');
|
||||
}
|
||||
|
||||
// Revalidate requests and ebook status
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { fetchEbook, isLoading, error };
|
||||
}
|
||||
|
||||
export function useInteractiveSearchEbookByAsin() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const searchEbooks = async (asin: string, customTitle?: string) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/audiobooks/${asin}/interactive-search-ebook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to search for ebooks');
|
||||
}
|
||||
|
||||
return data.results || [];
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { searchEbooks, isLoading, error };
|
||||
}
|
||||
|
||||
export function useSelectEbookByAsin() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectEbook = async (asin: string, ebook: any) => {
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/audiobooks/${asin}/select-ebook`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ebook }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to download ebook');
|
||||
}
|
||||
|
||||
// Revalidate requests and ebook status
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
|
||||
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { selectEbook, isLoading, error };
|
||||
}
|
||||
|
||||
@@ -44,12 +44,14 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed requests + soft-deleted requests (orphaned downloads)
|
||||
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
|
||||
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
|
||||
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
|
||||
// Before deleting torrent, we check if other active requests are using it
|
||||
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
|
||||
const completedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
|
||||
OR: [
|
||||
// Active requests that are fully available (scanned by Plex/ABS)
|
||||
{
|
||||
@@ -148,11 +150,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
|
||||
// CRITICAL: Check if any other active (non-deleted) request is using this same torrent hash
|
||||
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
|
||||
// This prevents deleting shared torrents when user re-requests the same audiobook
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
type: 'audiobook', // Only check audiobook requests
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Component: Direct Download Job Processors
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Handles direct HTTP downloads for ebooks from Anna's Archive.
|
||||
* Reports progress similar to qBittorrent/SABnzbd for unified UI.
|
||||
*/
|
||||
|
||||
import { StartDirectDownloadPayload, MonitorDirectDownloadPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { extractDownloadUrl, ExtractedDownload } from '../services/ebook-scraper';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 120000; // 2 minutes per download attempt
|
||||
const MAX_DOWNLOAD_ATTEMPTS = 5;
|
||||
const PROGRESS_UPDATE_INTERVAL_MS = 2000; // Update progress every 2 seconds
|
||||
|
||||
// In-memory tracking for active downloads
|
||||
interface ActiveDownload {
|
||||
id: string;
|
||||
requestId: string;
|
||||
downloadHistoryId: string;
|
||||
targetPath: string;
|
||||
bytesDownloaded: number;
|
||||
bytesTotal: number;
|
||||
startTime: number;
|
||||
lastUpdateTime: number;
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const activeDownloads = new Map<string, ActiveDownload>();
|
||||
|
||||
/**
|
||||
* Generate unique download ID
|
||||
*/
|
||||
function generateDownloadId(): string {
|
||||
return `dl_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process start direct download job
|
||||
* Initiates the HTTP download and schedules monitoring
|
||||
*/
|
||||
export async function processStartDirectDownload(payload: StartDirectDownloadPayload): Promise<any> {
|
||||
const { requestId, downloadHistoryId, downloadUrl, targetFilename, expectedSize, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'DirectDownload');
|
||||
|
||||
logger.info(`Starting direct download for request ${requestId}`);
|
||||
|
||||
try {
|
||||
// Update request status to downloading
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
downloadAttempts: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update download history
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'downloading',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get download configuration
|
||||
const configService = getConfigService();
|
||||
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
// Get all download URLs from download history (stored as JSON in torrentUrl)
|
||||
const downloadHistory = await prisma.downloadHistory.findUnique({
|
||||
where: { id: downloadHistoryId },
|
||||
});
|
||||
|
||||
let downloadUrls: string[] = [];
|
||||
try {
|
||||
downloadUrls = downloadHistory?.torrentUrl ? JSON.parse(downloadHistory.torrentUrl) : [downloadUrl];
|
||||
} catch {
|
||||
downloadUrls = [downloadUrl];
|
||||
}
|
||||
|
||||
logger.info(`Have ${downloadUrls.length} download URL(s) to try`);
|
||||
|
||||
// Try each slow download URL until one succeeds
|
||||
let downloadResult: { success: boolean; filePath?: string; format?: string; error?: string } = {
|
||||
success: false,
|
||||
error: 'No download URLs available',
|
||||
};
|
||||
|
||||
const attemptsLimit = Math.min(downloadUrls.length, MAX_DOWNLOAD_ATTEMPTS);
|
||||
|
||||
for (let i = 0; i < attemptsLimit; i++) {
|
||||
const slowLink = downloadUrls[i];
|
||||
logger.info(`Attempting download link ${i + 1}/${attemptsLimit}...`);
|
||||
|
||||
try {
|
||||
// Extract actual download URL from slow download page
|
||||
const extracted = await extractDownloadUrl(
|
||||
slowLink,
|
||||
baseUrl,
|
||||
preferredFormat,
|
||||
logger,
|
||||
flaresolverrUrl
|
||||
);
|
||||
|
||||
if (!extracted) {
|
||||
logger.warn(`No download URL found on page ${i + 1}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Downloading from: ${new URL(extracted.url).host} (format: ${extracted.format})`);
|
||||
|
||||
// Build target path with actual format
|
||||
const sanitizedFilename = sanitizeFilename(`${targetFilename.replace(/\.[^.]+$/, '')}.${extracted.format}`);
|
||||
const targetPath = path.join(downloadsDir, sanitizedFilename);
|
||||
|
||||
// Create download tracking entry
|
||||
const downloadId = generateDownloadId();
|
||||
const downloadEntry: ActiveDownload = {
|
||||
id: downloadId,
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
targetPath,
|
||||
bytesDownloaded: 0,
|
||||
bytesTotal: expectedSize || 0,
|
||||
startTime: Date.now(),
|
||||
lastUpdateTime: Date.now(),
|
||||
completed: false,
|
||||
failed: false,
|
||||
};
|
||||
activeDownloads.set(downloadId, downloadEntry);
|
||||
|
||||
// Start download with progress tracking
|
||||
const success = await downloadFileWithProgress(
|
||||
extracted.url,
|
||||
targetPath,
|
||||
downloadEntry,
|
||||
logger
|
||||
);
|
||||
|
||||
if (success) {
|
||||
downloadResult = {
|
||||
success: true,
|
||||
filePath: targetPath,
|
||||
format: extracted.format,
|
||||
};
|
||||
|
||||
// Get final file size
|
||||
try {
|
||||
const stats = await fs.stat(targetPath);
|
||||
downloadEntry.bytesTotal = stats.size;
|
||||
downloadEntry.bytesDownloaded = stats.size;
|
||||
} catch {
|
||||
// Ignore stat errors
|
||||
}
|
||||
|
||||
logger.info(`Download completed: ${sanitizedFilename}`);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.warn(`Download attempt ${i + 1} failed`);
|
||||
activeDownloads.delete(downloadId);
|
||||
} catch (error) {
|
||||
logger.warn(`Download link ${i + 1} error: ${error instanceof Error ? error.message : 'Unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!downloadResult.success) {
|
||||
// All attempts failed
|
||||
logger.error(`All ${attemptsLimit} download attempts failed`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: downloadResult.error || 'All download attempts failed',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'failed',
|
||||
downloadError: downloadResult.error || 'All download attempts failed',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Download failed',
|
||||
requestId,
|
||||
error: downloadResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Download succeeded - update records and trigger organize
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'completed',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get audiobook ID for organize job
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { audiobook: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error('Request not found after download');
|
||||
}
|
||||
|
||||
// Trigger organize files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
request.audiobookId,
|
||||
downloadResult.filePath!
|
||||
);
|
||||
|
||||
logger.info(`Download complete, triggered organize job for ${downloadResult.filePath}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Download completed, organizing files',
|
||||
requestId,
|
||||
filePath: downloadResult.filePath,
|
||||
format: downloadResult.format,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error during download',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistoryId },
|
||||
data: {
|
||||
downloadStatus: 'failed',
|
||||
downloadError: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file with progress tracking
|
||||
*/
|
||||
async function downloadFileWithProgress(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
tracking: ActiveDownload,
|
||||
logger: RMABLogger
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Ensure target directory exists
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
// Start download with axios streaming
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
responseType: 'stream',
|
||||
timeout: DOWNLOAD_TIMEOUT_MS,
|
||||
headers: {
|
||||
'User-Agent': 'ReadMeABook/1.0 (Audiobook Automation)',
|
||||
},
|
||||
});
|
||||
|
||||
// Get content length if available
|
||||
const contentLength = parseInt(response.headers['content-length'] || '0', 10);
|
||||
if (contentLength > 0) {
|
||||
tracking.bytesTotal = contentLength;
|
||||
}
|
||||
|
||||
// Create write stream
|
||||
const writer = createWriteStream(targetPath);
|
||||
|
||||
// Track progress
|
||||
let bytesDownloaded = 0;
|
||||
let lastLogTime = Date.now();
|
||||
let lastDbUpdateTime = Date.now();
|
||||
|
||||
response.data.on('data', (chunk: Buffer) => {
|
||||
bytesDownloaded += chunk.length;
|
||||
tracking.bytesDownloaded = bytesDownloaded;
|
||||
tracking.lastUpdateTime = Date.now();
|
||||
|
||||
// Log and update database every 2 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastLogTime >= 2000) {
|
||||
const percent = tracking.bytesTotal > 0
|
||||
? Math.round((bytesDownloaded / tracking.bytesTotal) * 100)
|
||||
: 0;
|
||||
const speedMBps = bytesDownloaded / ((now - tracking.startTime) / 1000) / (1024 * 1024);
|
||||
logger.info(`Download progress: ${percent}% (${(bytesDownloaded / (1024 * 1024)).toFixed(1)} MB, ${speedMBps.toFixed(2)} MB/s)`);
|
||||
lastLogTime = now;
|
||||
|
||||
// Update database with progress (non-blocking)
|
||||
if (now - lastDbUpdateTime >= PROGRESS_UPDATE_INTERVAL_MS) {
|
||||
lastDbUpdateTime = now;
|
||||
|
||||
// Non-blocking update - fire and forget
|
||||
prisma.request.update({
|
||||
where: { id: tracking.requestId },
|
||||
data: {
|
||||
progress: Math.min(percent, 99), // Cap at 99% until fully complete
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}).catch(() => {}); // Ignore errors during progress update
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pipe to file
|
||||
response.data.pipe(writer);
|
||||
|
||||
// Wait for completion
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', () => {
|
||||
tracking.completed = true;
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
tracking.failed = true;
|
||||
tracking.error = error.message;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.data.on('error', (error: Error) => {
|
||||
tracking.failed = true;
|
||||
tracking.error = error.message;
|
||||
writer.close();
|
||||
// Clean up partial file
|
||||
fs.unlink(targetPath).catch(() => {});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
tracking.failed = true;
|
||||
tracking.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Clean up partial file
|
||||
try {
|
||||
await fs.unlink(targetPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process monitor direct download job
|
||||
* Checks download progress and updates database
|
||||
* Note: For direct downloads, most tracking happens in processStartDirectDownload
|
||||
* This is kept for potential future use with async downloads
|
||||
*/
|
||||
export async function processMonitorDirectDownload(payload: MonitorDirectDownloadPayload): Promise<any> {
|
||||
const { requestId, downloadHistoryId, downloadId, targetPath, expectedSize, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'MonitorDirectDownload');
|
||||
|
||||
// Check if download is tracked
|
||||
const download = activeDownloads.get(downloadId);
|
||||
|
||||
if (!download) {
|
||||
// Download not in memory - check file existence
|
||||
try {
|
||||
const stats = await fs.stat(targetPath);
|
||||
logger.info(`Download file exists: ${targetPath} (${stats.size} bytes)`);
|
||||
|
||||
// If file exists and is complete, assume success
|
||||
if (expectedSize && stats.size >= expectedSize) {
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
message: 'Download already completed',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
logger.warn(`Download ${downloadId} not found in tracking`);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Download not found',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
// Update database with progress
|
||||
const progress = download.bytesTotal > 0
|
||||
? Math.min(99, Math.round((download.bytesDownloaded / download.bytesTotal) * 100))
|
||||
: 0;
|
||||
|
||||
const elapsed = Date.now() - download.startTime;
|
||||
const speed = elapsed > 0 ? download.bytesDownloaded / (elapsed / 1000) : 0;
|
||||
const eta = speed > 0 && download.bytesTotal > download.bytesDownloaded
|
||||
? Math.round((download.bytesTotal - download.bytesDownloaded) / speed)
|
||||
: 0;
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
progress,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (download.completed) {
|
||||
logger.info(`Download ${downloadId} completed`);
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
requestId,
|
||||
bytesDownloaded: download.bytesDownloaded,
|
||||
bytesTotal: download.bytesTotal,
|
||||
};
|
||||
}
|
||||
|
||||
if (download.failed) {
|
||||
logger.error(`Download ${downloadId} failed: ${download.error}`);
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
requestId,
|
||||
error: download.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Still in progress - schedule another monitor
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addMonitorDirectDownloadJob(
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
downloadId,
|
||||
targetPath,
|
||||
expectedSize,
|
||||
PROGRESS_UPDATE_INTERVAL_MS / 1000
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completed: false,
|
||||
requestId,
|
||||
progress,
|
||||
speed,
|
||||
eta,
|
||||
bytesDownloaded: download.bytesDownloaded,
|
||||
bytesTotal: download.bytesTotal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename for filesystem
|
||||
*/
|
||||
function sanitizeFilename(filename: string): string {
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid chars
|
||||
.replace(/\s+/g, ' ') // Collapse spaces
|
||||
.trim()
|
||||
.substring(0, 200); // Limit length
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Component: Monitor RSS Feeds Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Monitors RSS feeds for new audiobook releases and matches against missing requests
|
||||
* Monitors RSS feeds for new releases and matches against missing requests (audiobooks and ebooks)
|
||||
*/
|
||||
|
||||
import { prisma } from '../db';
|
||||
@@ -57,7 +57,8 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
return { success: true, message: 'No RSS results', matched: 0 };
|
||||
}
|
||||
|
||||
// Get all active requests awaiting search (missing audiobooks)
|
||||
// Get all active requests awaiting search (audiobooks and ebooks)
|
||||
// Both types can be matched against RSS torrent feeds
|
||||
const missingRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'awaiting_search',
|
||||
@@ -73,7 +74,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
return { success: true, message: 'No missing requests', matched: 0 };
|
||||
}
|
||||
|
||||
// Match RSS results against missing audiobooks
|
||||
// Match RSS results against missing requests
|
||||
let matched = 0;
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
@@ -94,16 +95,27 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
if (hasAuthor && titleMatchCount >= 2) {
|
||||
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
||||
|
||||
// Trigger search job to process this request
|
||||
// Trigger appropriate search job based on request type
|
||||
try {
|
||||
await jobQueue.addSearchJob(request.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
matched++;
|
||||
logger.info(`Triggered search job for request ${request.id}`);
|
||||
if (request.type === 'ebook') {
|
||||
await jobQueue.addSearchEbookJob(request.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
matched++;
|
||||
logger.info(`Triggered ebook search job for request ${request.id}`);
|
||||
} else {
|
||||
await jobQueue.addSearchJob(request.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
matched++;
|
||||
logger.info(`Triggered audiobook search job for request ${request.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { generateFilesHash } from '../utils/files-hash';
|
||||
/**
|
||||
* Process organize files job
|
||||
* Moves completed downloads to media library in proper directory structure
|
||||
* Handles both audiobook and ebook request types with appropriate branching
|
||||
*/
|
||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
@@ -24,6 +25,27 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
logger.info(`Download path: ${downloadPath}`);
|
||||
|
||||
try {
|
||||
// Fetch request to determine type
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error(`Request ${requestId} not found`);
|
||||
}
|
||||
|
||||
const requestType = request.type || 'audiobook'; // Default to audiobook for backward compatibility
|
||||
logger.info(`Request type: ${requestType}`);
|
||||
|
||||
// Branch based on request type
|
||||
if (requestType === 'ebook') {
|
||||
return await processEbookOrganization(payload, request, logger);
|
||||
}
|
||||
|
||||
// Continue with audiobook organization flow
|
||||
// Update request status to processing
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
@@ -45,36 +67,53 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
logger.info(`Organizing: ${audiobook.title} by ${audiobook.author}`);
|
||||
|
||||
// Fetch year from multiple sources (priority order)
|
||||
// Fetch missing metadata from AudibleCache if needed
|
||||
// Year and narrator can both be part of path templates
|
||||
let year = audiobook.year || undefined;
|
||||
logger.info(`Initial year from audiobook record: ${year || 'null'}`);
|
||||
let narrator = audiobook.narrator || undefined;
|
||||
|
||||
if (!year && audiobook.audibleAsin) {
|
||||
logger.info(`No year in audiobook record, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`);
|
||||
logger.info(`Initial metadata from audiobook record: year=${year || 'null'}, narrator=${narrator || 'null'}`);
|
||||
|
||||
// Try to enrich missing metadata from AudibleCache
|
||||
if (audiobook.audibleAsin && (!year || !narrator)) {
|
||||
logger.info(`Missing metadata, attempting to fetch from AudibleCache for ASIN: ${audiobook.audibleAsin}`);
|
||||
|
||||
// Try AudibleCache (for popular/new releases)
|
||||
const audibleCache = await prisma.audibleCache.findUnique({
|
||||
where: { asin: audiobook.audibleAsin },
|
||||
select: { releaseDate: true },
|
||||
select: { releaseDate: true, narrator: true },
|
||||
});
|
||||
|
||||
if (audibleCache?.releaseDate) {
|
||||
logger.info(`Found AudibleCache entry with releaseDate: ${audibleCache.releaseDate}`);
|
||||
year = new Date(audibleCache.releaseDate).getFullYear();
|
||||
logger.info(`Extracted year ${year} from AudibleCache releaseDate`);
|
||||
if (audibleCache) {
|
||||
const updates: { year?: number; narrator?: string } = {};
|
||||
|
||||
// Update audiobook record with year for future use
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: { year },
|
||||
});
|
||||
logger.info(`Updated audiobook record with year ${year}`);
|
||||
// Extract year from releaseDate if missing
|
||||
if (!year && audibleCache.releaseDate) {
|
||||
year = new Date(audibleCache.releaseDate).getFullYear();
|
||||
updates.year = year;
|
||||
logger.info(`Extracted year ${year} from AudibleCache releaseDate`);
|
||||
}
|
||||
|
||||
// Get narrator if missing
|
||||
if (!narrator && audibleCache.narrator) {
|
||||
narrator = audibleCache.narrator;
|
||||
updates.narrator = narrator;
|
||||
logger.info(`Got narrator "${narrator}" from AudibleCache`);
|
||||
}
|
||||
|
||||
// Update audiobook record with enriched data for future use
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Updated audiobook record with enriched metadata`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`No year found in AudibleCache for ASIN ${audiobook.audibleAsin}`);
|
||||
logger.info(`No AudibleCache entry found for ASIN ${audiobook.audibleAsin}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final year value for path organization: ${year || 'null (year will be omitted from path)'}`)
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`)
|
||||
|
||||
// Get file organizer (reads media_dir from database config)
|
||||
const organizer = await getFileOrganizer();
|
||||
@@ -91,7 +130,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
{
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
narrator: audiobook.narrator || undefined,
|
||||
narrator,
|
||||
coverArtUrl: audiobook.coverArtUrl || undefined,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
year,
|
||||
@@ -149,6 +188,10 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
errors: result.errors,
|
||||
});
|
||||
|
||||
// Create ebook request if ebook downloads enabled (for audiobook requests only)
|
||||
// This replaces the old inline ebook sidecar download
|
||||
await createEbookRequestIfEnabled(requestId, audiobook, request.userId, result.targetPath, logger);
|
||||
|
||||
// Trigger filesystem scan if enabled (Plex or Audiobookshelf)
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
@@ -303,8 +346,10 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
|
||||
|
||||
// Check if this is a retryable error (transient filesystem issues or no files found)
|
||||
// These errors may resolve on retry (e.g., files still being extracted, permissions being set)
|
||||
const isRetryableError =
|
||||
errorMessage.includes('No audiobook files found') ||
|
||||
errorMessage.includes('No ebook files found') || // Ebook equivalent of above
|
||||
errorMessage.includes('ENOENT') || // File/directory not found
|
||||
errorMessage.includes('no such file or directory') ||
|
||||
errorMessage.includes('EACCES') || // Permission denied (might be temporary)
|
||||
@@ -433,3 +478,382 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK-SPECIFIC ORGANIZATION
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Process ebook organization (simplified flow compared to audiobooks)
|
||||
* - No metadata tagging
|
||||
* - No cover art download
|
||||
* - No files hash generation
|
||||
* - Sends "available" notification at downloaded state (terminal for ebooks)
|
||||
*/
|
||||
async function processEbookOrganization(
|
||||
payload: OrganizeFilesPayload,
|
||||
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
|
||||
logger: RMABLogger
|
||||
): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId } = payload;
|
||||
|
||||
logger.info(`Processing ebook organization for request ${requestId}`);
|
||||
|
||||
// Update request status to processing
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get book details (works for both audiobooks and ebooks)
|
||||
const book = await prisma.audiobook.findUnique({
|
||||
where: { id: audiobookId },
|
||||
});
|
||||
|
||||
if (!book) {
|
||||
throw new Error(`Book ${audiobookId} not found`);
|
||||
}
|
||||
|
||||
logger.info(`Organizing ebook: ${book.title} by ${book.author}`);
|
||||
|
||||
// Fetch missing metadata from AudibleCache (same pattern as audiobooks)
|
||||
// Year, narrator, series, seriesPart can all be part of path templates
|
||||
let year = book.year || undefined;
|
||||
let narrator = book.narrator || undefined;
|
||||
let series = book.series || undefined;
|
||||
let seriesPart = book.seriesPart || undefined;
|
||||
|
||||
logger.info(`Initial metadata from book record: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}`);
|
||||
|
||||
// Try to enrich missing metadata from AudibleCache
|
||||
if (book.audibleAsin && (!year || !narrator)) {
|
||||
logger.info(`Missing metadata, attempting to fetch from AudibleCache for ASIN: ${book.audibleAsin}`);
|
||||
|
||||
const audibleCache = await prisma.audibleCache.findUnique({
|
||||
where: { asin: book.audibleAsin },
|
||||
select: { releaseDate: true, narrator: true, },
|
||||
});
|
||||
|
||||
if (audibleCache) {
|
||||
const updates: { year?: number; narrator?: string } = {};
|
||||
|
||||
// Extract year from releaseDate if missing
|
||||
if (!year && audibleCache.releaseDate) {
|
||||
year = new Date(audibleCache.releaseDate).getFullYear();
|
||||
updates.year = year;
|
||||
logger.info(`Extracted year ${year} from AudibleCache releaseDate`);
|
||||
}
|
||||
|
||||
// Get narrator if missing
|
||||
if (!narrator && audibleCache.narrator) {
|
||||
narrator = audibleCache.narrator;
|
||||
updates.narrator = narrator;
|
||||
logger.info(`Got narrator "${narrator}" from AudibleCache`);
|
||||
}
|
||||
|
||||
// Update book record with enriched data for future use
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: updates,
|
||||
});
|
||||
logger.info(`Updated book record with enriched metadata`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`No AudibleCache entry found for ASIN ${book.audibleAsin}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`);
|
||||
|
||||
// Check if this is an indexer download (needs to keep source for seeding)
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
|
||||
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
|
||||
|
||||
// Get file organizer and template
|
||||
const organizer = await getFileOrganizer();
|
||||
const templateConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'audiobook_path_template' },
|
||||
});
|
||||
const template = templateConfig?.value || '{author}/{title} {asin}';
|
||||
|
||||
// Organize ebook files (organizer will detect ebook type and skip audio-specific processing)
|
||||
// Pass all metadata that could be used in path templates (same as audiobooks)
|
||||
const result = await organizer.organizeEbook(
|
||||
downloadPath,
|
||||
{
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator,
|
||||
asin: book.audibleAsin || undefined,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined,
|
||||
isIndexerDownload
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Ebook organization failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
logger.info(`Successfully moved ebook to ${result.targetPath}`);
|
||||
|
||||
// Update book record with file path
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: {
|
||||
filePath: result.targetPath,
|
||||
fileFormat: result.format || 'epub',
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update request to downloaded (terminal state for ebooks)
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloaded',
|
||||
progress: 100,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
|
||||
|
||||
// Send "available" notification for ebooks at downloaded state
|
||||
// (since ebooks don't transition to 'available' via Plex matching)
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_available',
|
||||
requestId,
|
||||
book.title,
|
||||
book.author,
|
||||
request.user.plexUsername || 'Unknown User'
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger filesystem scan if enabled (same as audiobooks)
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
const configKey = backendMode === 'audiobookshelf'
|
||||
? 'audiobookshelf.trigger_scan_after_import'
|
||||
: 'plex.trigger_scan_after_import';
|
||||
const scanEnabled = await configService.get(configKey);
|
||||
|
||||
logger.debug(`Ebook library scan check: backendMode=${backendMode}, configKey=${configKey}, scanEnabled=${scanEnabled}`);
|
||||
|
||||
if (scanEnabled === 'true') {
|
||||
try {
|
||||
const libraryService = await getLibraryService();
|
||||
const libraryId = backendMode === 'audiobookshelf'
|
||||
? await configService.get('audiobookshelf.library_id')
|
||||
: await configService.get('plex_audiobook_library_id');
|
||||
|
||||
if (libraryId) {
|
||||
await libraryService.triggerLibraryScan(libraryId);
|
||||
logger.info(`Triggered ${backendMode} filesystem scan for library ${libraryId}`);
|
||||
} else {
|
||||
logger.warn(`Library ID not configured for ${backendMode}, skipping scan`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger filesystem scan: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
|
||||
}
|
||||
|
||||
// Cleanup Usenet downloads if configured (same logic as audiobooks)
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for ebook download');
|
||||
|
||||
// downloadHistory was already fetched earlier in this function
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasNzbId: !!downloadHistory?.nzbId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
nzbId: downloadHistory?.nzbId || 'none',
|
||||
indexerId: downloadHistory?.indexerId || 'none',
|
||||
});
|
||||
|
||||
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
|
||||
|
||||
if (indexersConfig) {
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a Usenet indexer with cleanup enabled
|
||||
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
|
||||
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
// Check if it's a file or directory
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Remove directory and all contents
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
// Remove single file
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
|
||||
|
||||
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ebook organized successfully',
|
||||
requestId,
|
||||
audiobookId,
|
||||
targetPath: result.targetPath,
|
||||
format: result.format,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ebook request if ebook downloads are enabled
|
||||
* Called after audiobook organization completes
|
||||
*
|
||||
* Supports two ebook sources:
|
||||
* - Anna's Archive (ebook_annas_archive_enabled) - Currently implemented
|
||||
* - Indexer Search (ebook_indexer_search_enabled) - Future feature, gracefully skipped
|
||||
*/
|
||||
async function createEbookRequestIfEnabled(
|
||||
parentRequestId: string,
|
||||
audiobook: { id: string; title: string; author: string; audibleAsin: string | null },
|
||||
userId: string,
|
||||
targetPath: string,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
const configService = getConfigService();
|
||||
|
||||
// Check if auto-grab is enabled (default: true for backward compatibility)
|
||||
const autoGrabEnabled = await configService.get('ebook_auto_grab_enabled');
|
||||
if (autoGrabEnabled === 'false') {
|
||||
logger.info('Ebook auto-grab disabled, skipping automatic ebook request creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which ebook sources are enabled
|
||||
const annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled');
|
||||
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled');
|
||||
|
||||
// Legacy migration: check old key if new keys don't exist
|
||||
const legacyEnabled = await configService.get('ebook_sidecar_enabled');
|
||||
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true' ||
|
||||
(annasArchiveEnabled === null && legacyEnabled === 'true');
|
||||
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
|
||||
|
||||
// If no sources are enabled, skip ebook creation
|
||||
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
|
||||
logger.info('Ebook downloads disabled (no sources enabled), skipping ebook request creation');
|
||||
return;
|
||||
}
|
||||
|
||||
// At least one source is enabled - proceed with ebook request creation
|
||||
|
||||
// Check if an ebook request already exists for this parent
|
||||
const existingEbookRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
parentRequestId,
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEbookRequest) {
|
||||
logger.info(`Ebook request already exists for parent ${parentRequestId}: ${existingEbookRequest.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Creating ebook request for "${audiobook.title}" (parent: ${parentRequestId})`);
|
||||
|
||||
// Create new ebook request (auto-approved since parent was approved)
|
||||
const ebookRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId,
|
||||
audiobookId: audiobook.id,
|
||||
type: 'ebook',
|
||||
parentRequestId,
|
||||
status: 'pending', // Will trigger search_ebook job
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Created ebook request ${ebookRequest.id}`);
|
||||
|
||||
// Trigger ebook search job (Anna's Archive)
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchEbookJob(ebookRequest.id, {
|
||||
id: audiobook.id,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
});
|
||||
|
||||
logger.info(`Triggered search_ebook job for request ${ebookRequest.id}`);
|
||||
} catch (error) {
|
||||
// Don't fail the main audiobook organization if ebook request creation fails
|
||||
logger.error(`Failed to create ebook request: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,9 +249,11 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
}
|
||||
}
|
||||
|
||||
// Check for all non-terminal requests to match
|
||||
// Check for all non-terminal audiobook requests to match
|
||||
// Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -43,9 +43,11 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
return { enabled: false, remotePath: '', localPath: '' };
|
||||
};
|
||||
|
||||
// Find all active requests in awaiting_import status
|
||||
// Find all active audiobook requests in awaiting_import status
|
||||
// Note: Ebook requests use the same organize_files processor but with type branching
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
|
||||
status: 'awaiting_import',
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
logger.info('Starting retry job for requests awaiting search...');
|
||||
|
||||
try {
|
||||
// Find all active requests in awaiting_search status
|
||||
// Find all active requests (audiobook or ebook) in awaiting_search status
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
status: 'awaiting_search',
|
||||
@@ -43,20 +43,33 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
||||
};
|
||||
}
|
||||
|
||||
// Trigger search job for each request
|
||||
// Trigger appropriate search job for each request based on type
|
||||
const jobQueue = getJobQueueService();
|
||||
let triggered = 0;
|
||||
|
||||
for (const request of requests) {
|
||||
try {
|
||||
await jobQueue.addSearchJob(request.id, {
|
||||
id: request.audiobook.id,
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
asin: request.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
triggered++;
|
||||
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
|
||||
if (request.type === 'ebook') {
|
||||
// Ebook requests use ebook search (Anna's Archive, etc.)
|
||||
await jobQueue.addSearchEbookJob(request.id, {
|
||||
id: request.audiobook.id,
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
asin: request.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
triggered++;
|
||||
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
|
||||
} else {
|
||||
// Audiobook requests use indexer search (Prowlarr)
|
||||
await jobQueue.addSearchJob(request.id, {
|
||||
id: request.audiobook.id,
|
||||
title: request.audiobook.title,
|
||||
author: request.audiobook.author,
|
||||
asin: request.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
triggered++;
|
||||
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
|
||||
@@ -433,10 +433,12 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
logger.info(`No orphaned audiobooks found`);
|
||||
}
|
||||
|
||||
// 6. Match all non-terminal requests against library
|
||||
// 6. Match all non-terminal audiobook requests against library
|
||||
// Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status
|
||||
logger.info(`Checking for matchable requests...`);
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
||||
status: { notIn: ['available', 'cancelled'] },
|
||||
deletedAt: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Component: Search Ebook Job Processor
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*
|
||||
* Searches for ebook downloads using multiple sources:
|
||||
* 1. Anna's Archive (if enabled) - direct HTTP downloads
|
||||
* 2. Indexer Search (if enabled) - via Prowlarr with ebook categories
|
||||
*/
|
||||
|
||||
import { SearchEbookPayload, EbookSearchResult, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getProwlarrService } from '../integrations/prowlarr.service';
|
||||
import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
|
||||
|
||||
// Import ebook scraper functions for Anna's Archive
|
||||
import {
|
||||
searchByAsin,
|
||||
searchByTitle,
|
||||
getSlowDownloadLinks,
|
||||
} from '../services/ebook-scraper';
|
||||
|
||||
/**
|
||||
* Process search ebook job
|
||||
* Searches Anna's Archive first (if enabled), then falls back to indexer search (if enabled)
|
||||
*/
|
||||
export async function processSearchEbook(payload: SearchEbookPayload): Promise<any> {
|
||||
const { requestId, audiobook, preferredFormat: payloadFormat, jobId } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'SearchEbook');
|
||||
|
||||
logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`);
|
||||
|
||||
try {
|
||||
// Update request status to searching
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'searching',
|
||||
searchAttempts: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Get ebook configuration
|
||||
const configService = getConfigService();
|
||||
const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub';
|
||||
const annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled') === 'true';
|
||||
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled') === 'true';
|
||||
|
||||
logger.info(`Sources: Anna's Archive=${annasArchiveEnabled}, Indexer Search=${indexerSearchEnabled}`);
|
||||
logger.info(`Preferred format: ${preferredFormat}`);
|
||||
|
||||
// Track whether we found a result
|
||||
let annasArchiveResult: EbookSearchResult | null = null;
|
||||
let indexerResult: RankedEbookTorrent | null = null;
|
||||
|
||||
// ========== STEP 1: Try Anna's Archive (if enabled) ==========
|
||||
if (annasArchiveEnabled) {
|
||||
logger.info(`Searching Anna's Archive...`);
|
||||
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger);
|
||||
|
||||
if (annasArchiveResult) {
|
||||
logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`);
|
||||
} else {
|
||||
logger.info(`No results from Anna's Archive`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ==========
|
||||
if (!annasArchiveResult && indexerSearchEnabled) {
|
||||
logger.info(`Searching indexers...`);
|
||||
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger);
|
||||
|
||||
if (indexerResult) {
|
||||
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
|
||||
} else {
|
||||
logger.info(`No results from indexer search`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 3: Handle Results ==========
|
||||
if (!annasArchiveResult && !indexerResult) {
|
||||
// No results found from any source
|
||||
const enabledSources = [];
|
||||
if (annasArchiveEnabled) enabledSources.push("Anna's Archive");
|
||||
if (indexerSearchEnabled) enabledSources.push("Indexer Search");
|
||||
|
||||
const message = enabledSources.length > 0
|
||||
? `No ebook found on ${enabledSources.join(' or ')}. Will retry automatically.`
|
||||
: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.';
|
||||
|
||||
logger.warn(`No ebook found for request ${requestId}, marking as awaiting_search`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'awaiting_search',
|
||||
errorMessage: message,
|
||||
lastSearchAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'No ebook found, queued for re-search',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== STEP 4: Route to Appropriate Download ==========
|
||||
if (annasArchiveResult) {
|
||||
// Anna's Archive result → Direct download
|
||||
return await handleAnnasArchiveDownload(requestId, audiobook, annasArchiveResult, preferredFormat, logger);
|
||||
} else if (indexerResult) {
|
||||
// Indexer result → Torrent/NZB download (reuse audiobook processor)
|
||||
return await handleIndexerDownload(requestId, audiobook, indexerResult, preferredFormat, logger);
|
||||
}
|
||||
|
||||
// This should never be reached
|
||||
throw new Error('Unexpected state: no result to process');
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error during ebook search',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Anna's Archive for ebook
|
||||
*/
|
||||
async function searchAnnasArchive(
|
||||
audiobook: { title: string; author: string; asin?: string },
|
||||
preferredFormat: string,
|
||||
logger: RMABLogger
|
||||
): Promise<EbookSearchResult | null> {
|
||||
const configService = getConfigService();
|
||||
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
|
||||
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
|
||||
|
||||
if (flaresolverrUrl) {
|
||||
logger.info(`Using FlareSolverr at ${flaresolverrUrl}`);
|
||||
}
|
||||
|
||||
let md5: string | null = null;
|
||||
let searchMethod: 'asin' | 'title' = 'title';
|
||||
|
||||
// Try ASIN search first (exact match - best)
|
||||
if (audiobook.asin) {
|
||||
logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`);
|
||||
md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl);
|
||||
|
||||
if (md5) {
|
||||
logger.info(`Found via ASIN: ${md5}`);
|
||||
searchMethod = 'asin';
|
||||
} else {
|
||||
logger.info(`No ASIN results, trying title + author...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to title + author search
|
||||
if (!md5) {
|
||||
logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`);
|
||||
md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl);
|
||||
|
||||
if (md5) {
|
||||
logger.info(`Found via title search: ${md5}`);
|
||||
searchMethod = 'title';
|
||||
}
|
||||
}
|
||||
|
||||
if (!md5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get slow download links
|
||||
const slowLinks = await getSlowDownloadLinks(md5, baseUrl, logger, flaresolverrUrl);
|
||||
|
||||
if (slowLinks.length === 0) {
|
||||
logger.warn(`Found MD5 ${md5} but no download links available`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`Found ${slowLinks.length} download link(s) for MD5 ${md5}`);
|
||||
|
||||
return {
|
||||
md5,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
format: preferredFormat,
|
||||
downloadUrls: slowLinks,
|
||||
source: 'annas_archive',
|
||||
score: searchMethod === 'asin' ? 100 : 80,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexers for ebook torrents/NZBs
|
||||
*/
|
||||
async function searchIndexers(
|
||||
requestId: string,
|
||||
audiobook: { title: string; author: string },
|
||||
preferredFormat: string,
|
||||
logger: RMABLogger
|
||||
): Promise<RankedEbookTorrent | null> {
|
||||
const configService = getConfigService();
|
||||
|
||||
// Get enabled indexers from configuration
|
||||
const indexersConfigStr = await configService.get('prowlarr_indexers');
|
||||
|
||||
if (!indexersConfigStr) {
|
||||
logger.warn('No indexers configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
|
||||
if (indexersConfig.length === 0) {
|
||||
logger.warn('No indexers enabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
|
||||
const indexerPriorities = new Map<number, number>(
|
||||
indexersConfig.map((indexer: any) => [indexer.id, indexer.priority ?? 10])
|
||||
);
|
||||
|
||||
// Get flag configurations
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Group indexers by their EBOOK category configuration
|
||||
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
logger.info(`Group ${index + 1}: ${getGroupDescription(group)}`);
|
||||
});
|
||||
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
|
||||
// Build search query (title only - cast wide net, let ranking filter)
|
||||
const searchQuery = audiobook.title;
|
||||
|
||||
logger.info(`Searching for: "${searchQuery}"`);
|
||||
|
||||
// Search Prowlarr for each group and combine results
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.search(searchQuery, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
minSeeders: 0, // Ebooks may have fewer seeders
|
||||
maxResults: 100,
|
||||
});
|
||||
|
||||
logger.info(`Group ${i + 1} returned ${groupResults.length} results`);
|
||||
allResults.push(...groupResults);
|
||||
} catch (error) {
|
||||
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Continue with other groups even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${allResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log filter info (ebooks > 20MB will be filtered)
|
||||
const preFilterCount = allResults.length;
|
||||
const aboveThreshold = allResults.filter(r => (r.size / (1024 * 1024)) > 20);
|
||||
if (aboveThreshold.length > 0) {
|
||||
logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`);
|
||||
}
|
||||
|
||||
// Rank results with ebook-specific scoring
|
||||
// This filters out > 20MB and uses inverted size scoring
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
preferredFormat,
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: true, // Automatic mode - prevent wrong authors
|
||||
});
|
||||
|
||||
// Log filter results
|
||||
const postFilterCount = rankedResults.length;
|
||||
if (postFilterCount < preFilterCount) {
|
||||
logger.info(`Filtered out ${preFilterCount - postFilterCount} results > 20 MB`);
|
||||
}
|
||||
|
||||
// Dual threshold filtering (same as audiobooks)
|
||||
const filteredResults = rankedResults.filter(result =>
|
||||
result.score >= 50 && result.finalScore >= 50
|
||||
);
|
||||
|
||||
const disqualifiedByNegativeBonus = rankedResults.filter(result =>
|
||||
result.score >= 50 && result.finalScore < 50
|
||||
).length;
|
||||
|
||||
logger.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
|
||||
if (disqualifiedByNegativeBonus > 0) {
|
||||
logger.info(`${disqualifiedByNegativeBonus} ebooks disqualified by negative flag bonuses`);
|
||||
}
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
logger.warn(`No quality matches found (all below 50/100)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Select best result
|
||||
const bestResult = filteredResults[0];
|
||||
|
||||
// Log top 3 results with detailed breakdown
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
logger.info(`==================== EBOOK RANKING DEBUG ====================`);
|
||||
logger.info(`Requested Title: "${audiobook.title}"`);
|
||||
logger.info(`Requested Author: "${audiobook.author}"`);
|
||||
logger.info(`Preferred Format: ${preferredFormat}`);
|
||||
logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
|
||||
logger.info(`--------------------------------------------------------------`);
|
||||
for (let i = 0; i < top3.length; i++) {
|
||||
const result = top3[i];
|
||||
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
|
||||
|
||||
logger.info(`${i + 1}. "${result.title}"`);
|
||||
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
|
||||
logger.info(``);
|
||||
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
|
||||
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
|
||||
logger.info(` - Format Match: ${result.breakdown.formatScore.toFixed(1)}/10`);
|
||||
logger.info(` - Size Quality: ${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB)`);
|
||||
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
|
||||
logger.info(``);
|
||||
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
|
||||
if (result.bonusModifiers.length > 0) {
|
||||
for (const mod of result.bonusModifiers) {
|
||||
logger.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
logger.info(``);
|
||||
logger.info(` Final Score: ${result.finalScore.toFixed(1)}`);
|
||||
if (result.breakdown.notes.length > 0) {
|
||||
logger.info(` Notes: ${result.breakdown.notes.join(', ')}`);
|
||||
}
|
||||
if (i < top3.length - 1) {
|
||||
logger.info(`--------------------------------------------------------------`);
|
||||
}
|
||||
}
|
||||
logger.info(`==============================================================`);
|
||||
logger.info(`Selected best result: ${bestResult.title} (final score: ${bestResult.finalScore.toFixed(1)})`);
|
||||
|
||||
return bestResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Anna's Archive download (direct HTTP)
|
||||
*/
|
||||
async function handleAnnasArchiveDownload(
|
||||
requestId: string,
|
||||
audiobook: { title: string; author: string },
|
||||
result: EbookSearchResult,
|
||||
preferredFormat: string,
|
||||
logger: RMABLogger
|
||||
): Promise<any> {
|
||||
logger.info(`==================== EBOOK SEARCH RESULT ====================`);
|
||||
logger.info(`Source: Anna's Archive`);
|
||||
logger.info(`Title: "${audiobook.title}"`);
|
||||
logger.info(`Author: "${audiobook.author}"`);
|
||||
logger.info(`Format: ${preferredFormat}`);
|
||||
logger.info(`MD5: ${result.md5}`);
|
||||
logger.info(`Download Links: ${result.downloadUrls.length}`);
|
||||
logger.info(`Score: ${result.score}/100`);
|
||||
logger.info(`==============================================================`);
|
||||
|
||||
// Create download history record
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: "Anna's Archive",
|
||||
torrentName: `${audiobook.title} - ${audiobook.author}.${preferredFormat}`,
|
||||
torrentSizeBytes: null, // Unknown until download starts
|
||||
qualityScore: result.score,
|
||||
selected: true,
|
||||
downloadClient: 'direct', // Direct HTTP download
|
||||
downloadStatus: 'queued',
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger direct download job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addStartDirectDownloadJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
result.downloadUrls[0], // Start with first link
|
||||
`${audiobook.title} - ${audiobook.author}.${preferredFormat}`,
|
||||
undefined // Size unknown
|
||||
);
|
||||
|
||||
// Store all download URLs for retry purposes
|
||||
await prisma.downloadHistory.update({
|
||||
where: { id: downloadHistory.id },
|
||||
data: {
|
||||
torrentUrl: JSON.stringify(result.downloadUrls),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ebook via Anna's Archive, starting download`,
|
||||
requestId,
|
||||
source: 'annas_archive',
|
||||
searchResult: {
|
||||
md5: result.md5,
|
||||
format: result.format,
|
||||
score: result.score,
|
||||
downloadLinksCount: result.downloadUrls.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle indexer download (torrent/NZB via download-torrent processor)
|
||||
*/
|
||||
async function handleIndexerDownload(
|
||||
requestId: string,
|
||||
audiobook: { title: string; author: string },
|
||||
result: RankedEbookTorrent,
|
||||
preferredFormat: string,
|
||||
logger: RMABLogger
|
||||
): Promise<any> {
|
||||
logger.info(`==================== EBOOK SEARCH RESULT ====================`);
|
||||
logger.info(`Source: Indexer (${result.indexer})`);
|
||||
logger.info(`Title: "${audiobook.title}"`);
|
||||
logger.info(`Author: "${audiobook.author}"`);
|
||||
logger.info(`Torrent: "${result.title}"`);
|
||||
logger.info(`Size: ${(result.size / (1024 * 1024)).toFixed(1)} MB`);
|
||||
logger.info(`Seeders: ${result.seeders !== undefined ? result.seeders : 'N/A'}`);
|
||||
logger.info(`Final Score: ${result.finalScore.toFixed(1)}/100`);
|
||||
logger.info(`==============================================================`);
|
||||
|
||||
// Trigger download job using the SAME processor as audiobooks
|
||||
// The download-torrent processor is already generic and handles both torrent and NZB
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Fetch the request to get the parent audiobook ID for the download job
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
include: { parentRequest: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error(`Request ${requestId} not found`);
|
||||
}
|
||||
|
||||
// Use the parent audiobook's ID for the download job, or fall back to request ID
|
||||
const audiobookId = request.parentRequest?.id || request.id;
|
||||
|
||||
await jobQueue.addDownloadJob(requestId, {
|
||||
id: audiobookId,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
}, result);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ebook via indexer search, starting download`,
|
||||
requestId,
|
||||
source: 'prowlarr',
|
||||
resultsCount: 1,
|
||||
selectedTorrent: {
|
||||
title: result.title,
|
||||
score: result.score,
|
||||
finalScore: result.finalScore,
|
||||
seeders: result.seeders || 0,
|
||||
size: result.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -304,8 +304,9 @@ export async function downloadEbook(
|
||||
|
||||
/**
|
||||
* Step 1: Search Anna's Archive by ASIN and extract MD5 hash
|
||||
* Exported for use by search-ebook processor
|
||||
*/
|
||||
async function searchByAsin(
|
||||
export async function searchByAsin(
|
||||
asin: string,
|
||||
format: string,
|
||||
baseUrl: string,
|
||||
@@ -394,8 +395,9 @@ async function searchByAsin(
|
||||
|
||||
/**
|
||||
* Search Anna's Archive by title and author (fallback method)
|
||||
* Exported for use by search-ebook processor
|
||||
*/
|
||||
async function searchByTitle(
|
||||
export async function searchByTitle(
|
||||
title: string,
|
||||
author: string,
|
||||
format: string,
|
||||
@@ -486,8 +488,9 @@ async function searchByTitle(
|
||||
|
||||
/**
|
||||
* Step 3: Get slow download links from MD5 page (no waitlist only)
|
||||
* Exported for use by search-ebook processor
|
||||
*/
|
||||
async function getSlowDownloadLinks(
|
||||
export async function getSlowDownloadLinks(
|
||||
md5: string,
|
||||
baseUrl: string,
|
||||
logger?: RMABLogger,
|
||||
@@ -561,7 +564,7 @@ async function getSlowDownloadLinks(
|
||||
}
|
||||
}
|
||||
|
||||
interface ExtractedDownload {
|
||||
export interface ExtractedDownload {
|
||||
url: string;
|
||||
format: string;
|
||||
}
|
||||
@@ -570,8 +573,9 @@ interface ExtractedDownload {
|
||||
* Step 4: Extract actual download URL from slow download page
|
||||
* IMPORTANT: Supports dynamic file formats (not hardcoded to .epub)
|
||||
* Returns both URL and detected format
|
||||
* Exported for use by direct-download processor
|
||||
*/
|
||||
async function extractDownloadUrl(
|
||||
export async function extractDownloadUrl(
|
||||
slowDownloadUrl: string,
|
||||
baseUrl: string,
|
||||
format: string,
|
||||
|
||||
@@ -24,7 +24,11 @@ export type JobType =
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'send_notification';
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
| 'search_ebook'
|
||||
| 'start_direct_download'
|
||||
| 'monitor_direct_download';
|
||||
|
||||
export interface JobPayload {
|
||||
jobId?: string; // Database job ID (added automatically by addJob)
|
||||
@@ -95,6 +99,45 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
// Ebook-specific payload interfaces
|
||||
export interface SearchEbookPayload extends JobPayload {
|
||||
requestId: string;
|
||||
audiobook: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
asin?: string; // ASIN for Anna's Archive search (best match)
|
||||
};
|
||||
preferredFormat?: string; // epub, pdf, mobi, azw3 (default: from config)
|
||||
}
|
||||
|
||||
export interface EbookSearchResult {
|
||||
md5: string;
|
||||
title: string;
|
||||
author: string;
|
||||
format: string;
|
||||
fileSize?: number;
|
||||
downloadUrls: string[]; // Slow download URLs from Anna's Archive
|
||||
source: 'annas_archive'; // For future indexer support
|
||||
score: number; // Ranking score (for future multi-source ranking)
|
||||
}
|
||||
|
||||
export interface StartDirectDownloadPayload extends JobPayload {
|
||||
requestId: string;
|
||||
downloadHistoryId: string;
|
||||
downloadUrl: string;
|
||||
targetFilename: string;
|
||||
expectedSize?: number;
|
||||
}
|
||||
|
||||
export interface MonitorDirectDownloadPayload extends JobPayload {
|
||||
requestId: string;
|
||||
downloadHistoryId: string;
|
||||
downloadId: string; // Internal tracking ID
|
||||
targetPath: string; // Full path to the downloading file
|
||||
expectedSize?: number;
|
||||
}
|
||||
|
||||
export interface SendNotificationPayload extends JobPayload {
|
||||
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
|
||||
requestId: string;
|
||||
@@ -301,6 +344,22 @@ export class JobQueueService {
|
||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||
return await processSendNotification(job.data);
|
||||
});
|
||||
|
||||
// Ebook-specific processors
|
||||
this.queue.process('search_ebook', 3, async (job: BullJob<SearchEbookPayload>) => {
|
||||
const { processSearchEbook } = await import('../processors/search-ebook.processor');
|
||||
return await processSearchEbook(job.data);
|
||||
});
|
||||
|
||||
this.queue.process('start_direct_download', 3, async (job: BullJob<StartDirectDownloadPayload>) => {
|
||||
const { processStartDirectDownload } = await import('../processors/direct-download.processor');
|
||||
return await processStartDirectDownload(job.data);
|
||||
});
|
||||
|
||||
this.queue.process('monitor_direct_download', 5, async (job: BullJob<MonitorDirectDownloadPayload>) => {
|
||||
const { processMonitorDirectDownload } = await import('../processors/direct-download.processor');
|
||||
return await processMonitorDirectDownload(job.data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -635,6 +694,83 @@ export class JobQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK-SPECIFIC JOB METHODS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Add search ebook job (Anna's Archive search)
|
||||
*/
|
||||
async addSearchEbookJob(
|
||||
requestId: string,
|
||||
audiobook: { id: string; title: string; author: string; asin?: string },
|
||||
preferredFormat?: string
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'search_ebook',
|
||||
{
|
||||
requestId,
|
||||
audiobook,
|
||||
preferredFormat,
|
||||
} as SearchEbookPayload,
|
||||
{
|
||||
priority: 10, // High priority for user-initiated requests
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add start direct download job (HTTP download for ebooks)
|
||||
*/
|
||||
async addStartDirectDownloadJob(
|
||||
requestId: string,
|
||||
downloadHistoryId: string,
|
||||
downloadUrl: string,
|
||||
targetFilename: string,
|
||||
expectedSize?: number
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'start_direct_download',
|
||||
{
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
downloadUrl,
|
||||
targetFilename,
|
||||
expectedSize,
|
||||
} as StartDirectDownloadPayload,
|
||||
{
|
||||
priority: 9, // High priority - download selected ebook
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add monitor direct download job (tracks HTTP download progress)
|
||||
*/
|
||||
async addMonitorDirectDownloadJob(
|
||||
requestId: string,
|
||||
downloadHistoryId: string,
|
||||
downloadId: string,
|
||||
targetPath: string,
|
||||
expectedSize?: number,
|
||||
delaySeconds: number = 0
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'monitor_direct_download',
|
||||
{
|
||||
requestId,
|
||||
downloadHistoryId,
|
||||
downloadId,
|
||||
targetPath,
|
||||
expectedSize,
|
||||
} as MonitorDirectDownloadPayload,
|
||||
{
|
||||
priority: 5, // Medium priority
|
||||
delay: delaySeconds * 1000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job by ID
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface DeleteRequestResult {
|
||||
/**
|
||||
* Soft delete a request with intelligent cleanup of media files and torrents
|
||||
*
|
||||
* Logic:
|
||||
* Logic (audiobook requests):
|
||||
* 1. Check if request exists and is not already deleted
|
||||
* 2. For each download:
|
||||
* - If unlimited seeding (0): Log and keep seeding, no monitoring
|
||||
@@ -34,7 +34,15 @@ export interface DeleteRequestResult {
|
||||
* - If seeding requirement met: Delete torrent + files
|
||||
* - If still seeding: Keep in qBittorrent for cleanup job
|
||||
* 3. Delete media files (title folder only)
|
||||
* 4. Soft delete request (set deletedAt, deletedBy)
|
||||
* 4. Delete from backend library (Plex/ABS)
|
||||
* 5. Clear audiobook availability linkage
|
||||
* 6. Soft delete request (set deletedAt, deletedBy)
|
||||
*
|
||||
* Logic (ebook requests):
|
||||
* 1. Check if request exists and is not already deleted
|
||||
* 2. Delete ebook files only (leave audiobook files intact)
|
||||
* 3. Soft delete request (set deletedAt, deletedBy)
|
||||
* Note: No backend library deletion or audiobook linkage clearing for ebooks
|
||||
*/
|
||||
export async function deleteRequest(
|
||||
requestId: string,
|
||||
@@ -57,6 +65,7 @@ export async function deleteRequest(
|
||||
audibleAsin: true,
|
||||
plexGuid: true,
|
||||
absItemId: true,
|
||||
fileFormat: true,
|
||||
},
|
||||
},
|
||||
downloadHistory: {
|
||||
@@ -71,6 +80,10 @@ export async function deleteRequest(
|
||||
},
|
||||
});
|
||||
|
||||
// Determine request type (default to audiobook for backward compatibility)
|
||||
const requestType = (request as any)?.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
|
||||
if (!request) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -87,10 +100,11 @@ export async function deleteRequest(
|
||||
let torrentsKeptSeeding = 0;
|
||||
let torrentsKeptUnlimited = 0;
|
||||
|
||||
// 2. Handle downloads & seeding
|
||||
// 2. Handle downloads & seeding (skip for ebooks - they use direct HTTP downloads)
|
||||
const downloadHistory = request.downloadHistory[0];
|
||||
const skipTorrentHandling = isEbook; // Ebooks use direct downloads, not torrents/NZBs
|
||||
|
||||
if (downloadHistory && downloadHistory.indexerName) {
|
||||
if (!skipTorrentHandling && downloadHistory && downloadHistory.indexerName) {
|
||||
try {
|
||||
// Get indexer seeding configuration
|
||||
const { getConfigService } = await import('./config.service');
|
||||
@@ -186,7 +200,9 @@ export async function deleteRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete media files (title folder only)
|
||||
// 3. Delete media files
|
||||
// For audiobooks: delete entire title folder
|
||||
// For ebooks: delete only ebook files (leave audiobook files intact)
|
||||
let filesDeleted = false;
|
||||
try {
|
||||
const { getConfigService } = await import('./config.service');
|
||||
@@ -219,15 +235,34 @@ export async function deleteRequest(
|
||||
}
|
||||
);
|
||||
|
||||
// Check if folder exists and delete it
|
||||
// Check if folder exists
|
||||
try {
|
||||
await fs.access(titleFolderPath);
|
||||
|
||||
// Delete the title folder (not the author folder)
|
||||
await fs.rm(titleFolderPath, { recursive: true, force: true });
|
||||
if (isEbook) {
|
||||
// For ebooks: only delete ebook files, leave audiobook files intact
|
||||
const ebookExtensions = ['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.fb2', '.cbz', '.cbr'];
|
||||
const files = await fs.readdir(titleFolderPath);
|
||||
|
||||
logger.info(`Deleted media directory: ${titleFolderPath}`);
|
||||
filesDeleted = true;
|
||||
let deletedCount = 0;
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (ebookExtensions.includes(ext)) {
|
||||
const filePath = path.join(titleFolderPath, file);
|
||||
await fs.unlink(filePath);
|
||||
logger.info(`Deleted ebook file: ${file}`);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
filesDeleted = deletedCount > 0;
|
||||
logger.info(`Deleted ${deletedCount} ebook file(s) from: ${titleFolderPath}`);
|
||||
} else {
|
||||
// For audiobooks: delete the entire title folder
|
||||
await fs.rm(titleFolderPath, { recursive: true, force: true });
|
||||
logger.info(`Deleted media directory: ${titleFolderPath}`);
|
||||
filesDeleted = true;
|
||||
}
|
||||
} catch (accessError) {
|
||||
// Folder doesn't exist - that's okay
|
||||
logger.info(`Media directory not found: ${titleFolderPath}`);
|
||||
@@ -242,143 +277,188 @@ export async function deleteRequest(
|
||||
}
|
||||
|
||||
// 4. Delete from plex_library table and clear audiobook availability
|
||||
// Skip for ebooks - audiobook files and library entry should remain intact
|
||||
// This ensures the book immediately shows as NOT available when searching
|
||||
try {
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
if (!isEbook) {
|
||||
try {
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
// Delete from library backend (ABS or Plex)
|
||||
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
|
||||
// Audiobookshelf: delete the library item from ABS
|
||||
try {
|
||||
const { deleteABSItem } = await import('../services/audiobookshelf/api');
|
||||
await deleteABSItem(request.audiobook.absItemId);
|
||||
logger.info(
|
||||
`Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"`
|
||||
);
|
||||
} catch (absError) {
|
||||
logger.error(
|
||||
`Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`,
|
||||
{ error: absError instanceof Error ? absError.message : String(absError) }
|
||||
);
|
||||
// Continue with deletion even if ABS deletion fails
|
||||
// Delete from library backend (ABS or Plex)
|
||||
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
|
||||
// Audiobookshelf: delete the library item from ABS
|
||||
try {
|
||||
const { deleteABSItem } = await import('../services/audiobookshelf/api');
|
||||
await deleteABSItem(request.audiobook.absItemId);
|
||||
logger.info(
|
||||
`Deleted Audiobookshelf library item ${request.audiobook.absItemId} for "${request.audiobook.title}"`
|
||||
);
|
||||
} catch (absError) {
|
||||
logger.error(
|
||||
`Error deleting Audiobookshelf library item ${request.audiobook.absItemId}`,
|
||||
{ error: absError instanceof Error ? absError.message : String(absError) }
|
||||
);
|
||||
// Continue with deletion even if ABS deletion fails
|
||||
}
|
||||
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
|
||||
// Plex: delete the library item from Plex by ratingKey
|
||||
try {
|
||||
// Query plex_library table to get the ratingKey
|
||||
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
|
||||
where: { plexGuid: request.audiobook.plexGuid },
|
||||
select: { plexRatingKey: true },
|
||||
});
|
||||
|
||||
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
|
||||
const ratingKey = plexLibraryRecord.plexRatingKey;
|
||||
|
||||
// Get Plex config
|
||||
const plexServerUrl = (await configService.get('plex_url')) || '';
|
||||
const plexToken = (await configService.get('plex_token')) || '';
|
||||
|
||||
if (plexServerUrl && plexToken) {
|
||||
const { getPlexService } = await import('../integrations/plex.service');
|
||||
const plexService = getPlexService();
|
||||
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
|
||||
logger.info(
|
||||
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
|
||||
);
|
||||
}
|
||||
} catch (plexError) {
|
||||
logger.error(
|
||||
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
|
||||
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
|
||||
);
|
||||
// Continue with deletion even if Plex deletion fails
|
||||
}
|
||||
}
|
||||
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
|
||||
// Plex: delete the library item from Plex by ratingKey
|
||||
|
||||
// Delete ALL plex_library records matching this audiobook's title and author
|
||||
// This handles cases where there might be duplicate library records
|
||||
// and ensures the book doesn't show as "In Your Library" during searches
|
||||
try {
|
||||
// Query plex_library table to get the ratingKey
|
||||
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
|
||||
where: { plexGuid: request.audiobook.plexGuid },
|
||||
select: { plexRatingKey: true },
|
||||
// Find all matching library records (by title/author fuzzy match)
|
||||
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
title: {
|
||||
contains: request.audiobook.title.substring(0, 20),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
|
||||
const ratingKey = plexLibraryRecord.plexRatingKey;
|
||||
// Filter to exact matches (case-insensitive title and author)
|
||||
const exactMatches = matchingLibraryRecords.filter((record) => {
|
||||
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
|
||||
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
|
||||
return titleMatch && authorMatch;
|
||||
});
|
||||
|
||||
// Get Plex config
|
||||
const plexServerUrl = (await configService.get('plex_url')) || '';
|
||||
const plexToken = (await configService.get('plex_token')) || '';
|
||||
if (exactMatches.length > 0) {
|
||||
// Delete all exact matches
|
||||
const deletePromises = exactMatches.map((record) =>
|
||||
prisma.plexLibrary.delete({ where: { id: record.id } })
|
||||
);
|
||||
|
||||
if (plexServerUrl && plexToken) {
|
||||
const { getPlexService } = await import('../integrations/plex.service');
|
||||
const plexService = getPlexService();
|
||||
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
|
||||
logger.info(
|
||||
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
|
||||
}
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
logger.info(
|
||||
`Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
|
||||
logger.info(
|
||||
`No plex_library records found for "${request.audiobook.title}"`
|
||||
);
|
||||
}
|
||||
} catch (plexError) {
|
||||
} catch (libError) {
|
||||
logger.error(
|
||||
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
|
||||
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
|
||||
`Error deleting plex_library records`,
|
||||
{ error: libError instanceof Error ? libError.message : String(libError) }
|
||||
);
|
||||
// Continue with deletion even if Plex deletion fails
|
||||
// Continue with deletion even if library cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL plex_library records matching this audiobook's title and author
|
||||
// This handles cases where there might be duplicate library records
|
||||
// and ensures the book doesn't show as "In Your Library" during searches
|
||||
// Clear audiobook record linkage
|
||||
const updateData: any = {
|
||||
status: 'requested', // Reset to requested state
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Clear library linkage based on backend mode
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
updateData.absItemId = null;
|
||||
} else {
|
||||
updateData.plexGuid = null;
|
||||
}
|
||||
|
||||
await prisma.audiobook.update({
|
||||
where: { id: request.audiobook.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Cleared availability status for audiobook ${request.audiobook.id}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error clearing audiobook status`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
// Continue with deletion even if this fails
|
||||
}
|
||||
} else {
|
||||
logger.info(`Skipping backend library deletion for ebook request ${requestId}`);
|
||||
}
|
||||
|
||||
// 5. Delete child requests (ebook requests linked to this audiobook request)
|
||||
if (!isEbook) {
|
||||
try {
|
||||
// Find all matching library records (by title/author fuzzy match)
|
||||
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
|
||||
const childRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
title: {
|
||||
contains: request.audiobook.title.substring(0, 20),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
parentRequestId: requestId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Filter to exact matches (case-insensitive title and author)
|
||||
const exactMatches = matchingLibraryRecords.filter((record) => {
|
||||
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
|
||||
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
|
||||
return titleMatch && authorMatch;
|
||||
});
|
||||
if (childRequests.length > 0) {
|
||||
logger.info(`Found ${childRequests.length} child request(s) to delete`);
|
||||
|
||||
if (exactMatches.length > 0) {
|
||||
// Delete all exact matches
|
||||
const deletePromises = exactMatches.map((record) =>
|
||||
prisma.plexLibrary.delete({ where: { id: record.id } })
|
||||
);
|
||||
// Soft delete all child requests
|
||||
await prisma.request.updateMany({
|
||||
where: {
|
||||
parentRequestId: requestId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
deletedBy: adminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
logger.info(
|
||||
`Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`No plex_library records found for "${request.audiobook.title}"`
|
||||
);
|
||||
logger.info(`Soft-deleted ${childRequests.length} child request(s)`);
|
||||
}
|
||||
} catch (libError) {
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error deleting plex_library records`,
|
||||
{ error: libError instanceof Error ? libError.message : String(libError) }
|
||||
`Error deleting child requests for ${requestId}`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
// Continue with deletion even if library cleanup fails
|
||||
// Continue with parent deletion even if child deletion fails
|
||||
}
|
||||
|
||||
// Clear audiobook record linkage
|
||||
const updateData: any = {
|
||||
status: 'requested', // Reset to requested state
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Clear library linkage based on backend mode
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
updateData.absItemId = null;
|
||||
} else {
|
||||
updateData.plexGuid = null;
|
||||
}
|
||||
|
||||
await prisma.audiobook.update({
|
||||
where: { id: request.audiobook.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Cleared availability status for audiobook ${request.audiobook.id}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error clearing audiobook status`,
|
||||
{ error: error instanceof Error ? error.message : String(error) }
|
||||
);
|
||||
// Continue with deletion even if this fails
|
||||
}
|
||||
|
||||
// 5. Soft delete request
|
||||
// 6. Soft delete request
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
|
||||
@@ -168,7 +168,7 @@ export async function enrichAudiobooksWithMatches(
|
||||
// Always enrich with request status (check ANY user's requests)
|
||||
const asins = audiobooks.map(book => book.asin);
|
||||
|
||||
// Get all audiobook records for these ASINs with ALL requests
|
||||
// Get all audiobook records for these ASINs with ALL audiobook requests (not ebook requests)
|
||||
const audiobookRecords = await prisma.audiobook.findMany({
|
||||
where: {
|
||||
audibleAsin: { in: asins },
|
||||
@@ -179,6 +179,7 @@ export async function enrichAudiobooksWithMatches(
|
||||
requests: {
|
||||
where: {
|
||||
deletedAt: null, // Only include active (non-deleted) requests
|
||||
type: 'audiobook', // Only check audiobook requests, not ebook requests
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
+171
-50
@@ -19,7 +19,6 @@ import {
|
||||
checkDiskSpace,
|
||||
} from './chapter-merger';
|
||||
import { prisma } from '../db';
|
||||
import { downloadEbook } from '../services/ebook-scraper';
|
||||
import { substituteTemplate, type TemplateVariables } from './path-template.util';
|
||||
|
||||
export interface AudiobookMetadata {
|
||||
@@ -42,6 +41,13 @@ export interface OrganizationResult {
|
||||
coverArtFile?: string;
|
||||
}
|
||||
|
||||
export interface EbookOrganizationResult {
|
||||
success: boolean;
|
||||
targetPath: string;
|
||||
errors: string[];
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
issues: string[];
|
||||
@@ -399,55 +405,10 @@ export class FileOrganizer {
|
||||
}
|
||||
}
|
||||
|
||||
// E-book sidecar: Download accompanying e-book if enabled
|
||||
try {
|
||||
const ebookConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'ebook_sidecar_enabled' },
|
||||
});
|
||||
|
||||
const ebookEnabled = ebookConfig?.value === 'true';
|
||||
|
||||
if (ebookEnabled) {
|
||||
await logger?.info(`E-book sidecar enabled, searching for e-book...`);
|
||||
|
||||
// Get configuration
|
||||
const [formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
|
||||
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
|
||||
]);
|
||||
|
||||
const preferredFormat = formatConfig?.value || 'epub';
|
||||
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
|
||||
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
|
||||
|
||||
// Download e-book (will try ASIN first, then fall back to title+author)
|
||||
const ebookResult = await downloadEbook(
|
||||
audiobook.asin || '', // ASIN (optional - will fallback to title+author if empty)
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
targetPath, // Same directory as audiobook
|
||||
preferredFormat,
|
||||
baseUrl,
|
||||
logger ?? undefined,
|
||||
flaresolverrUrl
|
||||
);
|
||||
|
||||
if (ebookResult.success && ebookResult.filePath) {
|
||||
await logger?.info(`E-book downloaded: ${path.basename(ebookResult.filePath)}`);
|
||||
result.filesMovedCount++;
|
||||
} else {
|
||||
await logger?.warn(`E-book download failed: ${ebookResult.error}`);
|
||||
result.errors.push(`E-book sidecar: ${ebookResult.error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.warn(
|
||||
`E-book sidecar error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
result.errors.push('E-book sidecar failed');
|
||||
// Don't throw - audiobook organization continues
|
||||
}
|
||||
// NOTE: E-book downloads are now handled via first-class ebook requests
|
||||
// The createEbookRequestIfEnabled() function in organize-files.processor.ts
|
||||
// creates a separate ebook request that goes through the full job queue flow.
|
||||
// This replaces the old inline ebook sidecar download that happened here.
|
||||
|
||||
result.targetPath = targetPath;
|
||||
result.success = true;
|
||||
@@ -680,6 +641,166 @@ export class FileOrganizer {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize ebook file into proper directory structure
|
||||
* Simplified compared to audiobooks - no metadata tagging, cover art, or chapter merging
|
||||
* Supports both direct file paths (Anna's Archive) and directories (indexer downloads)
|
||||
*/
|
||||
async organizeEbook(
|
||||
downloadPath: string,
|
||||
metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string },
|
||||
template: string,
|
||||
loggerConfig?: LoggerConfig,
|
||||
isIndexerDownload: boolean = false
|
||||
): Promise<EbookOrganizationResult> {
|
||||
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
|
||||
|
||||
const result: EbookOrganizationResult = {
|
||||
success: false,
|
||||
targetPath: '',
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await logger?.info(`Organizing ebook: ${downloadPath}`);
|
||||
|
||||
const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr'];
|
||||
|
||||
// Find ebook file (handle both file and directory cases)
|
||||
const { ebookFile, baseSourcePath, isFile } = await this.findEbookFile(downloadPath, ebookFormats);
|
||||
|
||||
if (!ebookFile) {
|
||||
throw new Error(`No ebook files found in download (looking for: ${ebookFormats.join(', ')})`);
|
||||
}
|
||||
|
||||
// Build full path to source file
|
||||
const sourceFilePath = isFile ? downloadPath : path.join(baseSourcePath, ebookFile);
|
||||
await logger?.info(`Found ebook file: ${ebookFile}`);
|
||||
|
||||
// Detect format from extension
|
||||
const ext = path.extname(ebookFile).toLowerCase().slice(1);
|
||||
result.format = ext;
|
||||
await logger?.info(`Detected ebook format: ${ext}`);
|
||||
|
||||
// Build target directory using same template as audiobooks
|
||||
const targetDir = this.buildTargetPath(
|
||||
this.mediaDir,
|
||||
template,
|
||||
metadata.author,
|
||||
metadata.title,
|
||||
metadata.narrator,
|
||||
metadata.asin,
|
||||
metadata.year,
|
||||
metadata.series,
|
||||
metadata.seriesPart
|
||||
);
|
||||
|
||||
await logger?.info(`Target directory: ${targetDir}`);
|
||||
|
||||
// Create target directory
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// Build target filename (sanitize source filename)
|
||||
const sourceFilename = path.basename(ebookFile);
|
||||
const targetFilename = this.sanitizePath(sourceFilename);
|
||||
const targetPath = path.join(targetDir, targetFilename);
|
||||
|
||||
// Check if target already exists
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
await logger?.info(`Ebook already exists at target, skipping copy: ${targetFilename}`);
|
||||
result.success = true;
|
||||
result.targetPath = targetDir;
|
||||
return result;
|
||||
} catch {
|
||||
// File doesn't exist, continue with copy
|
||||
}
|
||||
|
||||
// Copy ebook file (do NOT delete original - may need for seeding or retry)
|
||||
await fs.copyFile(sourceFilePath, targetPath);
|
||||
await fs.chmod(targetPath, 0o644);
|
||||
|
||||
await logger?.info(`Copied ebook: ${targetFilename}`);
|
||||
|
||||
// Clean up source file ONLY for direct HTTP downloads (not indexer downloads which need to seed)
|
||||
if (!isIndexerDownload && isFile) {
|
||||
try {
|
||||
await fs.unlink(sourceFilePath);
|
||||
await logger?.info(`Cleaned up source file: ${sourceFilename}`);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} else if (isIndexerDownload) {
|
||||
await logger?.info(`Keeping source file for seeding: ${sourceFilename}`);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.targetPath = targetDir;
|
||||
|
||||
await logger?.info(`Ebook organization complete: ${targetFilename}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await logger?.error(`Ebook organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ebook file in download path (handles both single file and directory)
|
||||
*/
|
||||
private async findEbookFile(
|
||||
downloadPath: string,
|
||||
ebookFormats: string[]
|
||||
): Promise<{ ebookFile: string | null; baseSourcePath: string; isFile: boolean }> {
|
||||
let ebookFile: string | null = null;
|
||||
let isFile = false;
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
// Handle single file case
|
||||
isFile = true;
|
||||
const ext = path.extname(downloadPath).toLowerCase().slice(1);
|
||||
|
||||
if (ebookFormats.includes(ext)) {
|
||||
ebookFile = path.basename(downloadPath);
|
||||
}
|
||||
} else {
|
||||
// Handle directory case - find ebook files inside
|
||||
const files = await this.walkDirectory(downloadPath);
|
||||
|
||||
// Filter to ebook files and sort by preference (epub > pdf > others)
|
||||
const ebookFiles = files.filter(file => {
|
||||
const ext = path.extname(file).toLowerCase().slice(1);
|
||||
return ebookFormats.includes(ext);
|
||||
});
|
||||
|
||||
if (ebookFiles.length > 0) {
|
||||
// Sort by format preference
|
||||
ebookFiles.sort((a, b) => {
|
||||
const extA = path.extname(a).toLowerCase().slice(1);
|
||||
const extB = path.extname(b).toLowerCase().slice(1);
|
||||
const priorityOrder = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr'];
|
||||
return priorityOrder.indexOf(extA) - priorityOrder.indexOf(extB);
|
||||
});
|
||||
|
||||
ebookFile = ebookFiles[0];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Path doesn't exist or inaccessible
|
||||
}
|
||||
|
||||
return {
|
||||
ebookFile,
|
||||
baseSourcePath: downloadPath,
|
||||
isFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
*
|
||||
* Groups indexers by their category configuration to minimize API calls.
|
||||
* Indexers with identical categories are grouped together for a single search.
|
||||
* Supports separate audiobook and ebook category configurations per indexer.
|
||||
*/
|
||||
|
||||
export type CategoryType = 'audiobook' | 'ebook';
|
||||
|
||||
export interface IndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
priority?: number;
|
||||
categories?: number[];
|
||||
audiobookCategories?: number[]; // Categories for audiobook searches
|
||||
ebookCategories?: number[]; // Categories for ebook searches
|
||||
categories?: number[]; // Legacy field for backwards compatibility
|
||||
[key: string]: any; // Allow other properties
|
||||
}
|
||||
|
||||
@@ -20,38 +25,70 @@ export interface IndexerGroup {
|
||||
indexers: IndexerConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate categories from an indexer based on the category type.
|
||||
*
|
||||
* @param indexer - The indexer configuration
|
||||
* @param type - The category type ('audiobook' or 'ebook')
|
||||
* @returns Array of category IDs
|
||||
*/
|
||||
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
|
||||
if (type === 'ebook') {
|
||||
return indexer.ebookCategories && indexer.ebookCategories.length > 0
|
||||
? indexer.ebookCategories
|
||||
: [7020]; // Default ebook category
|
||||
}
|
||||
|
||||
// Audiobook - check new field first, then legacy field
|
||||
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
|
||||
return indexer.audiobookCategories;
|
||||
}
|
||||
if (indexer.categories && indexer.categories.length > 0) {
|
||||
return indexer.categories; // Legacy fallback
|
||||
}
|
||||
return [3030]; // Default audiobook category
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups indexers by their category configuration.
|
||||
* Indexers with identical category arrays are grouped together.
|
||||
*
|
||||
* @param indexers - Array of indexer configurations
|
||||
* @param type - The category type to group by ('audiobook' or 'ebook')
|
||||
* @returns Array of groups, each containing indexers with matching categories
|
||||
*
|
||||
* @example
|
||||
* const indexers = [
|
||||
* { id: 1, categories: [3030] },
|
||||
* { id: 2, categories: [3030] },
|
||||
* { id: 3, categories: [3030, 3010] },
|
||||
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
|
||||
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
|
||||
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
|
||||
* ];
|
||||
*
|
||||
* const groups = groupIndexersByCategories(indexers);
|
||||
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
|
||||
* // Result:
|
||||
* // [
|
||||
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
|
||||
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
|
||||
* // ]
|
||||
*
|
||||
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
|
||||
* // Result:
|
||||
* // [
|
||||
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
|
||||
* // ]
|
||||
*/
|
||||
export function groupIndexersByCategories(indexers: IndexerConfig[]): IndexerGroup[] {
|
||||
export function groupIndexersByCategories(
|
||||
indexers: IndexerConfig[],
|
||||
type: CategoryType = 'audiobook'
|
||||
): IndexerGroup[] {
|
||||
// Map to track unique category combinations
|
||||
// Key: sorted category IDs as string (e.g., "3030,3010")
|
||||
// Value: array of indexers with those categories
|
||||
const groupMap = new Map<string, IndexerConfig[]>();
|
||||
|
||||
for (const indexer of indexers) {
|
||||
// Get categories, default to [3030] (audiobooks) if not specified
|
||||
const categories = indexer.categories && indexer.categories.length > 0
|
||||
? indexer.categories
|
||||
: [3030];
|
||||
// Get categories for the specified type
|
||||
const categories = getCategoriesForType(indexer, type);
|
||||
|
||||
// Sort categories to ensure consistent grouping
|
||||
// [3030, 3010] and [3010, 3030] should be the same group
|
||||
|
||||
@@ -42,6 +42,18 @@ export interface RankTorrentsOptions {
|
||||
requireAuthor?: boolean; // Enforce author presence check (default: true)
|
||||
}
|
||||
|
||||
export interface EbookTorrentRequest {
|
||||
title: string;
|
||||
author: string;
|
||||
preferredFormat: string; // User's preferred format (epub, pdf, etc.)
|
||||
}
|
||||
|
||||
export interface RankEbookTorrentsOptions {
|
||||
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
|
||||
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
|
||||
requireAuthor?: boolean; // Enforce author presence check (default: true)
|
||||
}
|
||||
|
||||
export interface BonusModifier {
|
||||
type: 'indexer_priority' | 'indexer_flag' | 'custom';
|
||||
value: number; // Multiplier (e.g., 0.4 for 40%)
|
||||
@@ -67,6 +79,25 @@ export interface RankedTorrent extends TorrentResult {
|
||||
breakdown: ScoreBreakdown;
|
||||
}
|
||||
|
||||
export interface EbookScoreBreakdown {
|
||||
formatScore: number; // 0-10 points (match preferred = 10, else 0)
|
||||
sizeScore: number; // 0-15 points (inverted - smaller is better)
|
||||
seederScore: number; // 0-15 points (same as audiobooks)
|
||||
matchScore: number; // 0-60 points (same as audiobooks)
|
||||
totalScore: number;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface RankedEbookTorrent extends TorrentResult {
|
||||
score: number; // Base score (0-100)
|
||||
bonusModifiers: BonusModifier[];
|
||||
bonusPoints: number; // Sum of all bonus points
|
||||
finalScore: number; // score + bonusPoints
|
||||
rank: number;
|
||||
breakdown: EbookScoreBreakdown;
|
||||
ebookFormat?: string; // Detected ebook format (epub, pdf, mobi, etc.)
|
||||
}
|
||||
|
||||
export class RankingAlgorithm {
|
||||
/**
|
||||
* Rank all torrents and return sorted by finalScore (best first)
|
||||
@@ -300,6 +331,26 @@ export class RankingAlgorithm {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Normalize text for matching by handling CamelCase and punctuation separators
|
||||
* "VirginaEvans TheCorrespondent" → "virgina evans the correspondent"
|
||||
* "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
|
||||
* "Author_Name_Book" → "author name book"
|
||||
*/
|
||||
private normalizeForMatching(text: string): string {
|
||||
return text
|
||||
// Split CamelCase FIRST (before lowercasing): "TheCorrespondent" → "The Correspondent"
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
// Replace underscores with spaces (must be explicit since \w includes _)
|
||||
.replace(/_/g, ' ')
|
||||
// Replace other punctuation/separators with spaces (preserves apostrophes in contractions)
|
||||
.replace(/[^\w\s']/g, ' ')
|
||||
// Collapse multiple spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Score title/author match quality (60 points max)
|
||||
* Title similarity: 0-45 points (heavily weighted!)
|
||||
@@ -310,10 +361,22 @@ export class RankingAlgorithm {
|
||||
audiobook: AudiobookRequest,
|
||||
requireAuthor: boolean = true
|
||||
): number {
|
||||
// Normalize whitespace (multiple spaces → single space) for consistent matching
|
||||
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
const requestTitle = audiobook.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
const requestAuthor = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
// Normalize for matching (handles CamelCase, punctuation separators)
|
||||
const torrentTitle = this.normalizeForMatching(torrent.title);
|
||||
const requestTitle = this.normalizeForMatching(audiobook.title);
|
||||
|
||||
// Parse authors from RAW string first (preserving commas for splitting)
|
||||
// Then normalize individual authors for matching
|
||||
const requestAuthorRaw = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
const parsedAuthors = requestAuthorRaw
|
||||
.split(/,|&| and | - /)
|
||||
.map(a => a.trim())
|
||||
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
|
||||
|
||||
// Normalize parsed authors for matching (handles CamelCase in author names)
|
||||
const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a));
|
||||
// Combined normalized author string for fuzzy matching
|
||||
const requestAuthorNormalized = normalizedAuthors.join(' ');
|
||||
|
||||
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
|
||||
// Extract significant words (filter out common stop words)
|
||||
@@ -321,26 +384,37 @@ export class RankingAlgorithm {
|
||||
|
||||
const extractWords = (text: string, stopList: string[]): string[] => {
|
||||
return text
|
||||
// Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent"
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ') // Remove punctuation
|
||||
// Replace underscores with spaces (must be explicit since \w includes _)
|
||||
.replace(/_/g, ' ')
|
||||
// Remove other punctuation (but keep apostrophes for contractions)
|
||||
.replace(/[^\w\s']/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 0 && !stopList.includes(word));
|
||||
};
|
||||
|
||||
// Separate required words (outside parentheses/brackets) from optional words (inside)
|
||||
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
|
||||
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
|
||||
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
|
||||
// Work with original title format for bracket detection
|
||||
const originalTitle = audiobook.title.toLowerCase();
|
||||
|
||||
// Extract content in parentheses/brackets as optional
|
||||
const optionalPattern = /[(\[{]([^)\]}]+)[)\]}]/g;
|
||||
const optionalMatches: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = optionalPattern.exec(title)) !== null) {
|
||||
while ((match = optionalPattern.exec(originalTitle)) !== null) {
|
||||
optionalMatches.push(match[1]);
|
||||
}
|
||||
|
||||
// Remove parenthetical/bracketed content to get required portion
|
||||
const required = title.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
|
||||
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
|
||||
// Normalize the required portion (handles CamelCase, punctuation)
|
||||
const required = this.normalizeForMatching(requiredRaw);
|
||||
const optional = optionalMatches.join(' ');
|
||||
|
||||
return { required, optional };
|
||||
@@ -370,7 +444,7 @@ export class RankingAlgorithm {
|
||||
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
|
||||
// Only enforced in automatic mode (requireAuthor: true)
|
||||
// Interactive search (requireAuthor: false) shows all results
|
||||
if (requireAuthor && !this.checkAuthorPresence(torrentTitle, requestAuthor)) {
|
||||
if (requireAuthor && !this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors)) {
|
||||
// No high-confidence author match → reject to prevent wrong-author matches
|
||||
return 0;
|
||||
}
|
||||
@@ -378,6 +452,10 @@ export class RankingAlgorithm {
|
||||
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
||||
let titleScore = 0;
|
||||
|
||||
// Keep original torrent title (lowercased only) for metadata marker detection
|
||||
// Markers like [ ] ( ) : are removed by normalization but needed for suffix validation
|
||||
const torrentTitleOriginal = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Try matching with full title first, then fall back to required title (without parentheses)
|
||||
const titlesToTry = [requestTitle];
|
||||
if (requiredTitle !== requestTitle) {
|
||||
@@ -392,20 +470,37 @@ export class RankingAlgorithm {
|
||||
const beforeTitle = torrentTitle.substring(0, titleIndex);
|
||||
const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length);
|
||||
|
||||
// For metadata marker detection, try to find where the title starts in the ORIGINAL string
|
||||
// Search for key words from the title to locate position in original
|
||||
const titleWords = titleToMatch.split(/\s+/).filter(w => w.length > 2);
|
||||
let afterTitleOriginal = '';
|
||||
if (titleWords.length > 0) {
|
||||
// Find the last significant title word in the original string
|
||||
const lastTitleWord = titleWords[titleWords.length - 1];
|
||||
const lastWordIdxOriginal = torrentTitleOriginal.lastIndexOf(lastTitleWord);
|
||||
if (lastWordIdxOriginal !== -1) {
|
||||
afterTitleOriginal = torrentTitleOriginal.substring(lastWordIdxOriginal + lastTitleWord.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract significant words BEFORE the matched title
|
||||
const beforeWords = extractWords(beforeTitle, stopWords);
|
||||
|
||||
// Title is complete if:
|
||||
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
|
||||
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
|
||||
// Check ORIGINAL title for metadata markers ([ ] ( ) etc. not normalized away)
|
||||
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
|
||||
|
||||
// Check if afterTitle starts with author name (handles space-separated format like "Title Author Year")
|
||||
const afterStartsWithAuthor = requestAuthor.length > 2 &&
|
||||
afterTitle.trim().startsWith(requestAuthor);
|
||||
// Check if afterTitle starts with any author name (handles space-separated format like "Title Author Year")
|
||||
const afterStartsWithAuthor = normalizedAuthors.some(author =>
|
||||
author.length > 2 && afterTitle.trim().startsWith(author)
|
||||
);
|
||||
|
||||
// Check metadata markers in both normalized and original suffixes
|
||||
const hasMetadataSuffix = afterTitle === '' ||
|
||||
metadataMarkers.some(marker => afterTitle.startsWith(marker)) ||
|
||||
metadataMarkers.some(marker => afterTitleOriginal.startsWith(marker)) ||
|
||||
afterStartsWithAuthor;
|
||||
|
||||
// Check prefix validity:
|
||||
@@ -416,16 +511,32 @@ export class RankingAlgorithm {
|
||||
|
||||
// Check if title is immediately preceded by a metadata separator
|
||||
// This handles "Author - Series - 01 - Title" patterns
|
||||
// Check both normalized and original strings for separators
|
||||
const precedingText = beforeTitle.trimEnd();
|
||||
|
||||
// Also check original string for separators that got normalized away (like colons)
|
||||
let beforeTitleOriginal = '';
|
||||
if (titleWords.length > 0) {
|
||||
const firstTitleWord = titleWords[0];
|
||||
const firstWordIdxOriginal = torrentTitleOriginal.indexOf(firstTitleWord);
|
||||
if (firstWordIdxOriginal !== -1) {
|
||||
beforeTitleOriginal = torrentTitleOriginal.substring(0, firstWordIdxOriginal).trimEnd();
|
||||
}
|
||||
}
|
||||
|
||||
const titlePrecededBySeparator =
|
||||
precedingText.endsWith('-') ||
|
||||
precedingText.endsWith(':') ||
|
||||
precedingText.endsWith('—');
|
||||
precedingText.endsWith('—') ||
|
||||
beforeTitleOriginal.endsWith('-') ||
|
||||
beforeTitleOriginal.endsWith(':') ||
|
||||
beforeTitleOriginal.endsWith('—');
|
||||
|
||||
// Check if author name appears in the prefix
|
||||
// Check if any author name appears in the prefix
|
||||
// This handles "Author Name - Title" patterns
|
||||
const authorInPrefix = requestAuthor.length > 2 &&
|
||||
beforeTitle.includes(requestAuthor);
|
||||
const authorInPrefix = normalizedAuthors.some(author =>
|
||||
author.length > 2 && beforeTitle.includes(author)
|
||||
);
|
||||
|
||||
const hasAcceptablePrefix =
|
||||
hasNoWordsPrefix ||
|
||||
@@ -451,24 +562,18 @@ export class RankingAlgorithm {
|
||||
}
|
||||
|
||||
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
|
||||
// Parse requested authors (split on separators, filter out roles)
|
||||
const requestAuthors = requestAuthor
|
||||
.split(/,|&| and | - /)
|
||||
.map(a => a.trim())
|
||||
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
|
||||
|
||||
// Check how many authors appear in torrent title (exact substring match)
|
||||
const authorMatches = requestAuthors.filter(author =>
|
||||
const authorMatches = normalizedAuthors.filter(author =>
|
||||
torrentTitle.includes(author)
|
||||
);
|
||||
|
||||
let authorScore = 0;
|
||||
if (authorMatches.length > 0) {
|
||||
// Exact substring match → proportional credit
|
||||
authorScore = (authorMatches.length / requestAuthors.length) * 15;
|
||||
authorScore = (authorMatches.length / normalizedAuthors.length) * 15;
|
||||
} else {
|
||||
// No exact match → use fuzzy similarity for partial credit
|
||||
authorScore = compareTwoStrings(requestAuthor, torrentTitle) * 15;
|
||||
authorScore = compareTwoStrings(requestAuthorNormalized, torrentTitle) * 15;
|
||||
}
|
||||
|
||||
return Math.min(60, titleScore + authorScore);
|
||||
@@ -476,22 +581,16 @@ export class RankingAlgorithm {
|
||||
|
||||
/**
|
||||
* Check if author is present in torrent title with high confidence
|
||||
* Handles variations: middle initials, spacing, punctuation, name order
|
||||
* Uses pre-parsed and normalized authors array
|
||||
*
|
||||
* @param torrentTitle - Normalized torrent title (lowercase)
|
||||
* @param requestAuthor - Normalized author name (lowercase)
|
||||
* @param torrentTitle - Normalized torrent title (already processed by normalizeForMatching)
|
||||
* @param normalizedAuthors - Array of normalized author names (roles already filtered)
|
||||
* @returns true if at least ONE author is present with high confidence
|
||||
*/
|
||||
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
|
||||
// Parse multiple authors (same logic as Stage 3 author matching)
|
||||
const authors = requestAuthor
|
||||
.split(/,|&| and | - /)
|
||||
.map(a => a.trim())
|
||||
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
|
||||
|
||||
private checkAuthorPresenceWithParsed(torrentTitle: string, normalizedAuthors: string[]): boolean {
|
||||
// At least ONE author must match with high confidence
|
||||
return authors.some(author => {
|
||||
// Check 1: Exact substring match
|
||||
return normalizedAuthors.some(author => {
|
||||
// Check 1: Exact substring match (works well now that both are normalized)
|
||||
if (torrentTitle.includes(author)) {
|
||||
return true;
|
||||
}
|
||||
@@ -507,6 +606,7 @@ export class RankingAlgorithm {
|
||||
// Check 3: Core name components (first + last name present within 30 chars)
|
||||
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
|
||||
// Handles: "Brandon R. Sanderson" vs "Brandon Sanderson"
|
||||
// Now also handles: "VirginaEvans" → "virgina evans" (after normalization)
|
||||
const words = author.split(/\s+/).filter(w => w.length > 1);
|
||||
if (words.length >= 2) {
|
||||
const firstName = words[0];
|
||||
@@ -528,6 +628,27 @@ export class RankingAlgorithm {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if author is present in torrent title with high confidence
|
||||
* Handles variations: middle initials, spacing, punctuation, name order, CamelCase
|
||||
*
|
||||
* @param torrentTitle - Normalized torrent title (already processed by normalizeForMatching)
|
||||
* @param requestAuthor - Raw author string (will be parsed and normalized internally)
|
||||
* @returns true if at least ONE author is present with high confidence
|
||||
*/
|
||||
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
|
||||
// Parse multiple authors (same logic as Stage 3 author matching)
|
||||
const authors = requestAuthor
|
||||
.split(/,|&| and | - /)
|
||||
.map(a => a.trim())
|
||||
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
|
||||
|
||||
// Normalize each author for matching
|
||||
const normalizedAuthors = authors.map(a => this.normalizeForMatching(a));
|
||||
|
||||
return this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect format from torrent title
|
||||
*/
|
||||
@@ -622,6 +743,424 @@ export class RankingAlgorithm {
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK TORRENT RANKING (for indexer results)
|
||||
// Reuses scoreMatch() and scoreSeeders() from audiobook ranking
|
||||
// Uses ebook-specific format and size scoring
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Rank ebook torrents from indexers
|
||||
* Reuses title/author matching and seeder scoring from audiobook ranking
|
||||
* Uses ebook-specific format scoring (10 pts for match, 0 otherwise)
|
||||
* Uses inverted size scoring (smaller = better, > 20MB filtered)
|
||||
*
|
||||
* @param torrents - Array of torrent results from Prowlarr
|
||||
* @param ebook - Ebook request details (title, author, preferredFormat)
|
||||
* @param options - Optional configuration for ranking behavior
|
||||
*/
|
||||
rankEbookTorrents(
|
||||
torrents: TorrentResult[],
|
||||
ebook: EbookTorrentRequest,
|
||||
options: RankEbookTorrentsOptions = {}
|
||||
): RankedEbookTorrent[] {
|
||||
const {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor = true // Safe default: require author in automatic mode
|
||||
} = options;
|
||||
|
||||
// Filter out files > 20 MB (too large for ebooks)
|
||||
const filteredTorrents = torrents.filter((torrent) => {
|
||||
const sizeMB = torrent.size / (1024 * 1024);
|
||||
return sizeMB <= 20;
|
||||
});
|
||||
|
||||
const ranked = filteredTorrents.map((torrent) => {
|
||||
// Detect ebook format from title
|
||||
const detectedFormat = this.detectEbookFormat(torrent);
|
||||
|
||||
// Calculate base scores (0-100)
|
||||
// Reuse scoreMatch and scoreSeeders from audiobook ranking
|
||||
const formatScore = this.scoreEbookFormat(torrent, ebook.preferredFormat);
|
||||
const sizeScore = this.scoreEbookSize(torrent);
|
||||
const seederScore = this.scoreSeeders(torrent.seeders);
|
||||
const matchScore = this.scoreMatch(torrent, {
|
||||
title: ebook.title,
|
||||
author: ebook.author,
|
||||
}, requireAuthor);
|
||||
|
||||
const baseScore = formatScore + sizeScore + seederScore + matchScore;
|
||||
|
||||
// Calculate bonus modifiers (same as audiobooks)
|
||||
const bonusModifiers: BonusModifier[] = [];
|
||||
|
||||
// Indexer priority bonus (default: 10/25 = 40%)
|
||||
if (torrent.indexerId !== undefined) {
|
||||
const priority = indexerPriorities?.get(torrent.indexerId) ?? 10;
|
||||
const modifier = priority / 25; // Convert 1-25 to 0.04-1.0 (4%-100%)
|
||||
const points = baseScore * modifier;
|
||||
|
||||
bonusModifiers.push({
|
||||
type: 'indexer_priority',
|
||||
value: modifier,
|
||||
points: points,
|
||||
reason: `Indexer priority ${priority}/25 (${Math.round(modifier * 100)}%)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Flag bonuses/penalties (same as audiobooks)
|
||||
if (torrent.flags && torrent.flags.length > 0 && flagConfigs && flagConfigs.length > 0) {
|
||||
torrent.flags.forEach(torrentFlag => {
|
||||
const matchingConfig = flagConfigs.find(cfg =>
|
||||
cfg.name.trim().toLowerCase() === torrentFlag.trim().toLowerCase()
|
||||
);
|
||||
|
||||
if (matchingConfig) {
|
||||
const modifier = matchingConfig.modifier / 100;
|
||||
const points = baseScore * modifier;
|
||||
|
||||
bonusModifiers.push({
|
||||
type: 'indexer_flag',
|
||||
value: modifier,
|
||||
points: points,
|
||||
reason: `Flag "${torrentFlag}" (${matchingConfig.modifier > 0 ? '+' : ''}${matchingConfig.modifier}%)`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sum all bonus points
|
||||
const bonusPoints = bonusModifiers.reduce((sum, mod) => sum + mod.points, 0);
|
||||
|
||||
// Calculate final score
|
||||
const finalScore = baseScore + bonusPoints;
|
||||
|
||||
return {
|
||||
...torrent,
|
||||
score: baseScore,
|
||||
bonusModifiers,
|
||||
bonusPoints,
|
||||
finalScore,
|
||||
rank: 0, // Will be assigned after sorting
|
||||
breakdown: {
|
||||
formatScore,
|
||||
sizeScore,
|
||||
seederScore,
|
||||
matchScore,
|
||||
totalScore: baseScore,
|
||||
notes: this.generateEbookNotes(torrent, {
|
||||
formatScore,
|
||||
sizeScore,
|
||||
seederScore,
|
||||
matchScore,
|
||||
totalScore: baseScore,
|
||||
notes: [],
|
||||
}, ebook.preferredFormat),
|
||||
},
|
||||
ebookFormat: detectedFormat !== 'unknown' ? detectedFormat : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by finalScore descending (best first), then by publishDate descending (newest first)
|
||||
ranked.sort((a, b) => {
|
||||
if (b.finalScore !== a.finalScore) {
|
||||
return b.finalScore - a.finalScore;
|
||||
}
|
||||
return b.publishDate.getTime() - a.publishDate.getTime();
|
||||
});
|
||||
|
||||
// Assign ranks
|
||||
ranked.forEach((r, index) => {
|
||||
r.rank = index + 1;
|
||||
});
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score ebook format (10 points max)
|
||||
* Full points for matching preferred format, 0 otherwise
|
||||
*/
|
||||
private scoreEbookFormat(torrent: TorrentResult, preferredFormat: string): number {
|
||||
const detectedFormat = this.detectEbookFormat(torrent);
|
||||
const preferred = preferredFormat.toLowerCase();
|
||||
|
||||
// Exact match = full points, otherwise 0
|
||||
if (detectedFormat === preferred) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score ebook file size (15 points max, inverted - smaller is better)
|
||||
* < 5 MB = 15 pts (full)
|
||||
* 5-15 MB = 10 pts
|
||||
* 15-20 MB = 5 pts
|
||||
* > 20 MB = filtered out (not scored)
|
||||
*/
|
||||
private scoreEbookSize(torrent: TorrentResult): number {
|
||||
const sizeMB = torrent.size / (1024 * 1024);
|
||||
|
||||
if (sizeMB < 5) {
|
||||
return 15; // Optimal size for ebooks
|
||||
} else if (sizeMB <= 15) {
|
||||
return 10; // Acceptable, may have images
|
||||
} else if (sizeMB <= 20) {
|
||||
return 5; // Large but within limit
|
||||
}
|
||||
|
||||
// > 20 MB should have been filtered, but return 0 as safety
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect ebook format from torrent title
|
||||
* Handles formats in various positions: .epub, (epub), [epub], " epub"
|
||||
*/
|
||||
private detectEbookFormat(torrent: TorrentResult): string {
|
||||
const title = torrent.title.toLowerCase();
|
||||
|
||||
// Check for common ebook format extensions/keywords
|
||||
// Patterns: .format, (format), [format], " format", "_format"
|
||||
const formats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr'];
|
||||
|
||||
for (const format of formats) {
|
||||
if (
|
||||
title.includes(`.${format}`) || // file.epub
|
||||
title.includes(`(${format})`) || // (epub)
|
||||
title.includes(`[${format}]`) || // [epub]
|
||||
title.includes(` ${format}`) || // " epub" (space before)
|
||||
title.includes(`_${format}`) || // _epub (underscore)
|
||||
title.endsWith(format) // ends with format
|
||||
) {
|
||||
return format;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to unknown
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-readable notes for ebook scoring
|
||||
*/
|
||||
private generateEbookNotes(
|
||||
torrent: TorrentResult,
|
||||
breakdown: EbookScoreBreakdown,
|
||||
preferredFormat: string
|
||||
): string[] {
|
||||
const notes: string[] = [];
|
||||
|
||||
// Format notes
|
||||
const detectedFormat = this.detectEbookFormat(torrent);
|
||||
if (breakdown.formatScore === 10) {
|
||||
notes.push(`✓ Preferred format (${detectedFormat.toUpperCase()})`);
|
||||
} else if (detectedFormat !== 'unknown') {
|
||||
notes.push(`Different format (${detectedFormat.toUpperCase()}, wanted ${preferredFormat.toUpperCase()})`);
|
||||
} else {
|
||||
notes.push('⚠️ Unknown format');
|
||||
}
|
||||
|
||||
// Size notes
|
||||
const sizeMB = torrent.size / (1024 * 1024);
|
||||
if (sizeMB < 5) {
|
||||
notes.push('✓ Optimal file size');
|
||||
} else if (sizeMB <= 15) {
|
||||
notes.push('Good file size (may have images)');
|
||||
} else if (sizeMB <= 20) {
|
||||
notes.push('⚠️ Large file size');
|
||||
}
|
||||
|
||||
// Seeder notes (same logic as audiobooks)
|
||||
if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) {
|
||||
if (torrent.seeders === 0) {
|
||||
notes.push('⚠️ No seeders available');
|
||||
} else if (torrent.seeders < 5) {
|
||||
notes.push(`Low seeders (${torrent.seeders})`);
|
||||
} else if (torrent.seeders >= 50) {
|
||||
notes.push(`Excellent availability (${torrent.seeders} seeders)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Match notes (same thresholds as audiobooks)
|
||||
if (breakdown.matchScore < 24) {
|
||||
notes.push('⚠️ Poor title/author match');
|
||||
} else if (breakdown.matchScore < 42) {
|
||||
notes.push('⚠️ Weak title/author match');
|
||||
} else if (breakdown.matchScore >= 54) {
|
||||
notes.push('✓ Excellent title/author match');
|
||||
}
|
||||
|
||||
// Overall quality assessment
|
||||
if (breakdown.totalScore >= 75) {
|
||||
notes.push('✓ Excellent choice');
|
||||
} else if (breakdown.totalScore >= 55) {
|
||||
notes.push('✓ Good choice');
|
||||
} else if (breakdown.totalScore < 35) {
|
||||
notes.push('⚠️ Consider reviewing this choice');
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK RANKING (simplified algorithm for ebook search results)
|
||||
// =========================================================================
|
||||
|
||||
export interface EbookResult {
|
||||
md5: string;
|
||||
title: string;
|
||||
author: string;
|
||||
format: string; // epub, pdf, mobi, etc.
|
||||
fileSize?: number; // in bytes
|
||||
downloadUrls: string[];
|
||||
source: 'annas_archive' | 'prowlarr'; // Source of the result
|
||||
indexerId?: number; // Prowlarr indexer ID (if applicable)
|
||||
}
|
||||
|
||||
export interface EbookRequest {
|
||||
title: string;
|
||||
author: string;
|
||||
preferredFormat: string; // User's preferred format (epub, pdf, etc.)
|
||||
}
|
||||
|
||||
export interface RankedEbook extends EbookResult {
|
||||
score: number; // Total score (0-100)
|
||||
rank: number;
|
||||
breakdown: {
|
||||
formatScore: number; // 0-40 points
|
||||
sizeScore: number; // 0-30 points (inverted - smaller is better)
|
||||
sourceScore: number; // 0-30 points (Anna's Archive priority)
|
||||
notes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank ebook search results
|
||||
* Scoring priorities (inverted from audiobooks):
|
||||
* - Format match: 40 points (matching preferred format)
|
||||
* - Size: 30 points (smaller files = better, inverted from audiobooks)
|
||||
* - Source: 30 points (Anna's Archive priority for reliability)
|
||||
*/
|
||||
export function rankEbooks(
|
||||
results: EbookResult[],
|
||||
request: EbookRequest
|
||||
): RankedEbook[] {
|
||||
const preferredFormat = request.preferredFormat.toLowerCase();
|
||||
|
||||
const ranked = results.map((result): RankedEbook => {
|
||||
const notes: string[] = [];
|
||||
|
||||
// ========== FORMAT SCORING (0-40 points) ==========
|
||||
// Exact format match gets full points
|
||||
// Similar formats get partial credit
|
||||
let formatScore = 0;
|
||||
const resultFormat = result.format.toLowerCase();
|
||||
|
||||
if (resultFormat === preferredFormat) {
|
||||
formatScore = 40;
|
||||
notes.push(`✓ Preferred format (${result.format.toUpperCase()})`);
|
||||
} else {
|
||||
// Partial credit for compatible formats
|
||||
const ebookFormatGroups = [
|
||||
['epub', 'kepub'], // EPUB family
|
||||
['mobi', 'azw', 'azw3'], // Kindle family
|
||||
['pdf'], // PDF standalone
|
||||
['fb2', 'fb2.zip'], // FB2 family
|
||||
['cbz', 'cbr'], // Comic formats
|
||||
];
|
||||
|
||||
const preferredGroup = ebookFormatGroups.find(g => g.includes(preferredFormat));
|
||||
const resultGroup = ebookFormatGroups.find(g => g.includes(resultFormat));
|
||||
|
||||
if (preferredGroup && resultGroup && preferredGroup === resultGroup) {
|
||||
formatScore = 30; // Same family
|
||||
notes.push(`Similar format (${result.format.toUpperCase()})`);
|
||||
} else if (resultFormat === 'epub') {
|
||||
formatScore = 25; // EPUB is universally convertible
|
||||
notes.push(`Convertible format (${result.format.toUpperCase()})`);
|
||||
} else if (resultFormat === 'pdf') {
|
||||
formatScore = 15; // PDF is common but less flexible
|
||||
notes.push(`PDF format (less flexible)`);
|
||||
} else {
|
||||
formatScore = 10; // Other formats
|
||||
notes.push(`Different format (${result.format.toUpperCase()})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SIZE SCORING (0-30 points, inverted) ==========
|
||||
// For ebooks, smaller files are generally better (cleaner, no bloat)
|
||||
// Typical ebook sizes: 0.5-5 MB (good), 5-20 MB (has images), 20+ MB (may have issues)
|
||||
let sizeScore = 0;
|
||||
|
||||
if (result.fileSize !== undefined && result.fileSize > 0) {
|
||||
const sizeMB = result.fileSize / (1024 * 1024);
|
||||
|
||||
if (sizeMB <= 2) {
|
||||
sizeScore = 30; // Ideal size
|
||||
notes.push('✓ Optimal file size');
|
||||
} else if (sizeMB <= 5) {
|
||||
sizeScore = 25; // Good size
|
||||
notes.push('Good file size');
|
||||
} else if (sizeMB <= 15) {
|
||||
sizeScore = 20; // Has images, acceptable
|
||||
notes.push('Larger file (may have images)');
|
||||
} else if (sizeMB <= 50) {
|
||||
sizeScore = 10; // Large, possibly bloated
|
||||
notes.push('⚠️ Large file size');
|
||||
} else {
|
||||
sizeScore = 5; // Very large, suspicious
|
||||
notes.push('⚠️ Very large file (may include extras)');
|
||||
}
|
||||
} else {
|
||||
// No size info - give middle score
|
||||
sizeScore = 15;
|
||||
notes.push('File size unknown');
|
||||
}
|
||||
|
||||
// ========== SOURCE SCORING (0-30 points) ==========
|
||||
// Anna's Archive is the primary reliable source
|
||||
// Future: Prowlarr indexers will get configurable priority
|
||||
let sourceScore = 0;
|
||||
|
||||
if (result.source === 'annas_archive') {
|
||||
sourceScore = 30; // Full points for Anna's Archive
|
||||
notes.push('✓ Anna\'s Archive (reliable)');
|
||||
} else if (result.source === 'prowlarr') {
|
||||
// Future: Use indexer priority from config
|
||||
sourceScore = 15; // Base score for Prowlarr results
|
||||
notes.push('Prowlarr indexer');
|
||||
}
|
||||
|
||||
const totalScore = formatScore + sizeScore + sourceScore;
|
||||
|
||||
return {
|
||||
...result,
|
||||
score: totalScore,
|
||||
rank: 0, // Will be assigned after sorting
|
||||
breakdown: {
|
||||
formatScore,
|
||||
sizeScore,
|
||||
sourceScore,
|
||||
notes,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Assign ranks
|
||||
ranked.forEach((r, index) => {
|
||||
r.rank = index + 1;
|
||||
});
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
@@ -689,3 +1228,26 @@ export function rankTorrents(
|
||||
qualityScore: Math.round(r.score),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to rank ebook torrents using the singleton instance
|
||||
*
|
||||
* @param torrents - Array of torrent results from Prowlarr
|
||||
* @param ebook - Ebook request details (title, author, preferredFormat)
|
||||
* @param options - Optional ranking configuration
|
||||
* @returns Ranked ebook torrents with quality scores
|
||||
*/
|
||||
export function rankEbookTorrents(
|
||||
torrents: TorrentResult[],
|
||||
ebook: EbookTorrentRequest,
|
||||
options?: RankEbookTorrentsOptions
|
||||
): (RankedEbookTorrent & { qualityScore: number })[] {
|
||||
const algorithm = getRankingAlgorithm();
|
||||
const ranked = algorithm.rankEbookTorrents(torrents, ebook, options || {});
|
||||
|
||||
// Add qualityScore field for UI compatibility (rounded score)
|
||||
return ranked.map((r) => ({
|
||||
...r,
|
||||
qualityScore: Math.round(r.score),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ export const TORRENT_CATEGORIES: TorrentCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_CATEGORIES = [3030]; // Audio/Audiobook
|
||||
export const DEFAULT_AUDIOBOOK_CATEGORIES = [3030]; // Audio/Audiobook
|
||||
export const DEFAULT_EBOOK_CATEGORIES = [7020]; // Books/EBook
|
||||
|
||||
// Legacy alias for backwards compatibility
|
||||
export const DEFAULT_CATEGORIES = DEFAULT_AUDIOBOOK_CATEGORIES;
|
||||
|
||||
/**
|
||||
* Get all child IDs for a parent category
|
||||
|
||||
@@ -20,6 +20,7 @@ const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
addDownloadJob: vi.fn(),
|
||||
addNotificationJob: vi.fn(() => Promise.resolve()),
|
||||
addSearchEbookJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const downloadEbookMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
@@ -355,42 +356,75 @@ describe('Request action routes', () => {
|
||||
expect(payload.error).toMatch(/Cannot fetch e-book/);
|
||||
});
|
||||
|
||||
it('returns 400 when audiobook directory is missing', async () => {
|
||||
it('creates ebook request and triggers search job', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
// Mock parent request lookup
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
userId: 'user-1',
|
||||
audiobookId: 'ab-1',
|
||||
status: 'downloaded',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
|
||||
// Mock check for existing ebook request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
// Mock ebook request creation
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'ebook-req-1',
|
||||
type: 'ebook',
|
||||
parentRequestId: 'req-6',
|
||||
});
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/directory not found/);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/created/i);
|
||||
expect(payload.requestId).toBe('ebook-req-1');
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
type: 'ebook',
|
||||
parentRequestId: 'req-6',
|
||||
status: 'pending',
|
||||
}),
|
||||
});
|
||||
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith(
|
||||
'ebook-req-1',
|
||||
expect.objectContaining({
|
||||
id: 'ab-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
asin: 'ASIN123',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads ebook and returns success', async () => {
|
||||
it('retries existing failed ebook request', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
configState.values.set('media_dir', '/media/audiobooks');
|
||||
configState.values.set('audiobook_path_template', '{author}/{title} {asin}');
|
||||
configState.values.set('ebook_sidecar_preferred_format', 'epub');
|
||||
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
|
||||
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
|
||||
|
||||
// Mock parent request lookup
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
userId: 'user-1',
|
||||
audiobookId: 'ab-1',
|
||||
status: 'available',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' });
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
downloadEbookMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
format: 'epub',
|
||||
filePath: '/media/audiobooks/Author/Title ASIN123/Title.epub',
|
||||
|
||||
// Mock existing failed ebook request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'ebook-req-existing',
|
||||
status: 'failed',
|
||||
});
|
||||
|
||||
// Mock update for retry
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'ebook-req-existing',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
@@ -398,29 +432,35 @@ describe('Request action routes', () => {
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(downloadEbookMock).toHaveBeenCalledWith(
|
||||
'ASIN123',
|
||||
'Title',
|
||||
'Author',
|
||||
expect.stringContaining('Title ASIN123'),
|
||||
'epub',
|
||||
'https://ebooks.example',
|
||||
undefined,
|
||||
'http://flaresolverr'
|
||||
);
|
||||
expect(payload.message).toMatch(/retried/i);
|
||||
expect(payload.requestId).toBe('ebook-req-existing');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ebook-req-existing' },
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
}),
|
||||
});
|
||||
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns failure payload when ebook download fails', async () => {
|
||||
it('returns message when ebook request already exists and in progress', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
// Mock parent request lookup
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-8',
|
||||
userId: 'user-1',
|
||||
audiobookId: 'ab-1',
|
||||
status: 'downloaded',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
downloadEbookMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Download failed',
|
||||
|
||||
// Mock existing in-progress ebook request
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'ebook-req-existing',
|
||||
status: 'downloading',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
@@ -428,7 +468,11 @@ describe('Request action routes', () => {
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(false);
|
||||
expect(payload.message).toMatch(/Download failed/);
|
||||
expect(payload.message).toMatch(/already exists/i);
|
||||
expect(payload.requestId).toBe('ebook-req-existing');
|
||||
// Should not create new request or trigger search
|
||||
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Component: Direct Download Processor Tests
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addOrganizeJob: vi.fn(() => Promise.resolve()),
|
||||
addMonitorDirectDownloadJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const ebookScraperMock = vi.hoisted(() => ({
|
||||
extractDownloadUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn(),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const createWriteStreamMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
createWriteStream: createWriteStreamMock,
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
}));
|
||||
|
||||
describe('processStartDirectDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'downloads_dir') return '/downloads';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('updates request status to downloading', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
|
||||
});
|
||||
|
||||
// Mock successful download
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
|
||||
url: 'https://direct.example.com/book.epub',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
// Mock axios stream
|
||||
const mockWriteStream = {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === 'finish') setTimeout(cb, 10);
|
||||
return mockWriteStream;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
|
||||
const mockDataStream = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
pipe: vi.fn().mockReturnValue(mockWriteStream),
|
||||
};
|
||||
axiosMock.mockResolvedValue({
|
||||
data: mockDataStream,
|
||||
headers: { 'content-length': '1000000' },
|
||||
});
|
||||
|
||||
fsMock.stat.mockResolvedValue({ size: 1000000 });
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
audiobookId: 'ab-1',
|
||||
audiobook: { id: 'ab-1' },
|
||||
});
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processStartDirectDownload({
|
||||
requestId: 'req-1',
|
||||
downloadHistoryId: 'dh-1',
|
||||
downloadUrl: 'https://slow.example.com/book',
|
||||
targetFilename: 'Test Book.epub',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
// Check status updates
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dh-1' },
|
||||
data: expect.objectContaining({
|
||||
downloadStatus: 'downloading',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers organize job after successful download', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
|
||||
});
|
||||
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
|
||||
url: 'https://direct.example.com/book.epub',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
const mockWriteStream = {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === 'finish') setTimeout(cb, 10);
|
||||
return mockWriteStream;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
|
||||
const mockDataStream = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
pipe: vi.fn().mockReturnValue(mockWriteStream),
|
||||
};
|
||||
axiosMock.mockResolvedValue({
|
||||
data: mockDataStream,
|
||||
headers: { 'content-length': '500000' },
|
||||
});
|
||||
|
||||
fsMock.stat.mockResolvedValue({ size: 500000 });
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-2',
|
||||
audiobookId: 'ab-2',
|
||||
audiobook: { id: 'ab-2' },
|
||||
});
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processStartDirectDownload({
|
||||
requestId: 'req-2',
|
||||
downloadHistoryId: 'dh-2',
|
||||
downloadUrl: 'https://slow.example.com/book2',
|
||||
targetFilename: 'Another Book.epub',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-2',
|
||||
'ab-2',
|
||||
expect.stringContaining('Another Book.epub')
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request as failed when all download attempts fail', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify([
|
||||
'https://slow1.example.com/book',
|
||||
'https://slow2.example.com/book',
|
||||
]),
|
||||
});
|
||||
|
||||
// All extract attempts fail
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue(null);
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processStartDirectDownload({
|
||||
requestId: 'req-3',
|
||||
downloadHistoryId: 'dh-3',
|
||||
downloadUrl: 'https://slow1.example.com/book',
|
||||
targetFilename: 'Failed Book.epub',
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Verify the second call (final failure status update)
|
||||
expect(prismaMock.request.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'req-3' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
}),
|
||||
});
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'dh-3' },
|
||||
data: expect.objectContaining({
|
||||
downloadStatus: 'failed',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses FlareSolverr when configured', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
|
||||
});
|
||||
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'downloads_dir') return '/downloads';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
||||
return null;
|
||||
});
|
||||
|
||||
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
|
||||
url: 'https://direct.example.com/book.epub',
|
||||
format: 'epub',
|
||||
});
|
||||
|
||||
const mockWriteStream = {
|
||||
on: vi.fn((event, cb) => {
|
||||
if (event === 'finish') setTimeout(cb, 10);
|
||||
return mockWriteStream;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
|
||||
const mockDataStream = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
pipe: vi.fn().mockReturnValue(mockWriteStream),
|
||||
};
|
||||
axiosMock.mockResolvedValue({
|
||||
data: mockDataStream,
|
||||
headers: { 'content-length': '500000' },
|
||||
});
|
||||
|
||||
fsMock.stat.mockResolvedValue({ size: 500000 });
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-4',
|
||||
audiobookId: 'ab-4',
|
||||
audiobook: { id: 'ab-4' },
|
||||
});
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
await processStartDirectDownload({
|
||||
requestId: 'req-4',
|
||||
downloadHistoryId: 'dh-4',
|
||||
downloadUrl: 'https://slow.example.com/book',
|
||||
targetFilename: 'Flare Book.epub',
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(ebookScraperMock.extractDownloadUrl).toHaveBeenCalledWith(
|
||||
'https://slow.example.com/book',
|
||||
'https://annas-archive.li',
|
||||
'epub',
|
||||
expect.anything(),
|
||||
'http://flaresolverr:8191'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles errors and updates request status', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.findUnique.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
await expect(processStartDirectDownload({
|
||||
requestId: 'req-5',
|
||||
downloadHistoryId: 'dh-5',
|
||||
downloadUrl: 'https://slow.example.com/book',
|
||||
targetFilename: 'Error Book.epub',
|
||||
jobId: 'job-5',
|
||||
})).rejects.toThrow('Database error');
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-5' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
errorMessage: 'Database error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processMonitorDirectDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns completed status when download file exists', async () => {
|
||||
fsMock.stat.mockResolvedValue({ size: 1000000 });
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processMonitorDirectDownload({
|
||||
requestId: 'req-m1',
|
||||
downloadHistoryId: 'dh-m1',
|
||||
downloadId: 'dl_unknown',
|
||||
targetPath: '/downloads/book.epub',
|
||||
expectedSize: 1000000,
|
||||
jobId: 'job-m1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.completed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns not found when download is not tracked', async () => {
|
||||
fsMock.stat.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor');
|
||||
|
||||
const result = await processMonitorDirectDownload({
|
||||
requestId: 'req-m2',
|
||||
downloadHistoryId: 'dh-m2',
|
||||
downloadId: 'dl_missing',
|
||||
targetPath: '/downloads/missing.epub',
|
||||
expectedSize: 500000,
|
||||
jobId: 'job-m2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,12 @@ vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
describe('processOrganizeFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for request lookup (processor needs to determine request type)
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-default',
|
||||
type: 'audiobook', // Default to audiobook type
|
||||
user: { plexUsername: 'testuser' },
|
||||
});
|
||||
});
|
||||
|
||||
it('organizes files and triggers filesystem scan when enabled', async () => {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Component: Search Ebook Processor Tests
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addStartDirectDownloadJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
const ebookScraperMock = vi.hoisted(() => ({
|
||||
searchByAsin: vi.fn(),
|
||||
searchByTitle: vi.fn(),
|
||||
getSlowDownloadLinks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
|
||||
|
||||
describe('processSearchEbook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('searches by ASIN when available and triggers download', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('abc123md5');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://slow1.example.com/abc123',
|
||||
'https://slow2.example.com/abc123',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-1',
|
||||
audiobook: {
|
||||
id: 'ab-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
asin: 'B001ASIN',
|
||||
},
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('ASIN');
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||
'B001ASIN',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
expect.anything(),
|
||||
undefined
|
||||
);
|
||||
expect(jobQueueMock.addStartDirectDownloadJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'dh-1',
|
||||
'https://slow1.example.com/abc123',
|
||||
'Test Book - Test Author.epub',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to title search when ASIN search fails', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue(null);
|
||||
ebookScraperMock.searchByTitle.mockResolvedValue('xyz789md5');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://slow1.example.com/xyz789',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-2',
|
||||
audiobook: {
|
||||
id: 'ab-2',
|
||||
title: 'Another Book',
|
||||
author: 'Another Author',
|
||||
asin: 'B002ASIN',
|
||||
},
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('title search');
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalled();
|
||||
expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith(
|
||||
'Another Book',
|
||||
'Another Author',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
expect.anything(),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('searches by title when no ASIN is available', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-3' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByTitle.mockResolvedValue('noasin123');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://slow.example.com/noasin123',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-3',
|
||||
audiobook: {
|
||||
id: 'ab-3',
|
||||
title: 'No ASIN Book',
|
||||
author: 'No ASIN Author',
|
||||
// No asin field
|
||||
},
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(ebookScraperMock.searchByAsin).not.toHaveBeenCalled();
|
||||
expect(ebookScraperMock.searchByTitle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request as awaiting_search when no ebook found', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue(null);
|
||||
ebookScraperMock.searchByTitle.mockResolvedValue(null);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-4',
|
||||
audiobook: {
|
||||
id: 'ab-4',
|
||||
title: 'Unfindable Book',
|
||||
author: 'Unknown Author',
|
||||
asin: 'B004ASIN',
|
||||
},
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('re-search');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-4' },
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_search',
|
||||
errorMessage: expect.stringContaining('No ebook found'),
|
||||
}),
|
||||
});
|
||||
expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request as awaiting_search when no download links available', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('md5nolinks');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
const result = await processSearchEbook({
|
||||
requestId: 'req-5',
|
||||
audiobook: {
|
||||
id: 'ab-5',
|
||||
title: 'Book No Links',
|
||||
author: 'Author No Links',
|
||||
asin: 'B005ASIN',
|
||||
},
|
||||
jobId: 'job-5',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('re-search');
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-5' },
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_search',
|
||||
errorMessage: expect.stringContaining('no download links'),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses FlareSolverr when configured', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-6' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'ebook_sidecar_preferred_format') return 'epub';
|
||||
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
|
||||
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
|
||||
return null;
|
||||
});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('md5withflare');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue(['https://slow.example.com/flare']);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
await processSearchEbook({
|
||||
requestId: 'req-6',
|
||||
audiobook: {
|
||||
id: 'ab-6',
|
||||
title: 'Flare Book',
|
||||
author: 'Flare Author',
|
||||
asin: 'B006ASIN',
|
||||
},
|
||||
jobId: 'job-6',
|
||||
});
|
||||
|
||||
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
||||
'B006ASIN',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
expect.anything(),
|
||||
'http://flaresolverr:8191'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails request on unexpected errors', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
await expect(processSearchEbook({
|
||||
requestId: 'req-7',
|
||||
audiobook: {
|
||||
id: 'ab-7',
|
||||
title: 'Error Book',
|
||||
author: 'Error Author',
|
||||
asin: 'B007ASIN',
|
||||
},
|
||||
jobId: 'job-7',
|
||||
})).rejects.toThrow('Network error');
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith({
|
||||
where: { id: 'req-7' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
errorMessage: 'Network error',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates download history with correct metadata', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-8' });
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
ebookScraperMock.searchByAsin.mockResolvedValue('md5metadata');
|
||||
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
|
||||
'https://link1.example.com',
|
||||
'https://link2.example.com',
|
||||
]);
|
||||
|
||||
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
|
||||
|
||||
await processSearchEbook({
|
||||
requestId: 'req-8',
|
||||
audiobook: {
|
||||
id: 'ab-8',
|
||||
title: 'Metadata Book',
|
||||
author: 'Metadata Author',
|
||||
asin: 'B008ASIN',
|
||||
},
|
||||
jobId: 'job-8',
|
||||
});
|
||||
|
||||
expect(prismaMock.downloadHistory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
requestId: 'req-8',
|
||||
indexerName: "Anna's Archive",
|
||||
torrentName: 'Metadata Book - Metadata Author.epub',
|
||||
downloadClient: 'direct',
|
||||
downloadStatus: 'queued',
|
||||
selected: true,
|
||||
qualityScore: 100, // ASIN match = 100
|
||||
}),
|
||||
});
|
||||
|
||||
// Check that all URLs are stored
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({
|
||||
where: { id: 'dh-8' },
|
||||
data: {
|
||||
torrentUrl: JSON.stringify([
|
||||
'https://link1.example.com',
|
||||
'https://link2.example.com',
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,10 @@ const processorsMock = vi.hoisted(() => ({
|
||||
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
// Ebook processors
|
||||
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
||||
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||
processMonitorDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||
}));
|
||||
|
||||
const queueMock = vi.hoisted(() => ({
|
||||
@@ -111,6 +115,16 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
|
||||
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
|
||||
}));
|
||||
|
||||
// Ebook processors
|
||||
vi.mock('@/lib/processors/search-ebook.processor', () => ({
|
||||
processSearchEbook: processorsMock.processSearchEbook,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/direct-download.processor', () => ({
|
||||
processStartDirectDownload: processorsMock.processStartDirectDownload,
|
||||
processMonitorDirectDownload: processorsMock.processMonitorDirectDownload,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,9 @@ vi.mock('@/lib/utils/file-organizer', () => ({
|
||||
describe('deleteRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for child request queries (audiobook requests check for child ebook requests)
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.updateMany.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
it('returns not found when request is missing', async () => {
|
||||
|
||||
@@ -275,17 +275,8 @@ describe('file organizer', () => {
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
});
|
||||
|
||||
it('downloads remote cover art and ebook sidecar when enabled', async () => {
|
||||
it('downloads remote cover art when no local cover exists', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
configState.values.set('ebook_sidecar_preferred_format', 'epub');
|
||||
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
|
||||
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
|
||||
|
||||
ebookMock.downloadEbook.mockResolvedValue({
|
||||
success: true,
|
||||
filePath: '/media/Author/Book/book.epub',
|
||||
});
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
@@ -322,18 +313,11 @@ describe('file organizer', () => {
|
||||
'https://images.example/cover.jpg',
|
||||
expect.objectContaining({ responseType: 'arraybuffer' })
|
||||
);
|
||||
expect(ebookMock.downloadEbook).toHaveBeenCalledWith(
|
||||
'ASIN123',
|
||||
'Book',
|
||||
'Author',
|
||||
expectedDir,
|
||||
'epub',
|
||||
'https://ebooks.example',
|
||||
undefined,
|
||||
'http://flaresolverr'
|
||||
);
|
||||
// NOTE: Ebook downloads are now handled as first-class requests through the job queue
|
||||
// The file organizer no longer downloads ebooks inline
|
||||
expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('records an error when cover art download fails', async () => {
|
||||
@@ -444,36 +428,9 @@ describe('file organizer', () => {
|
||||
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
|
||||
});
|
||||
|
||||
it('records ebook sidecar errors when download throws', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
}, '{author}/{title}');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toContain('E-book sidecar failed');
|
||||
});
|
||||
// NOTE: The ebook sidecar test was removed because ebook downloads are now
|
||||
// handled as first-class requests through the job queue, not inline during
|
||||
// file organization. See organize-files.processor.ts createEbookRequestIfEnabled().
|
||||
|
||||
it('finds audio files and cover art in nested folders', async () => {
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
|
||||
@@ -1034,6 +1034,159 @@ describe('ranking-algorithm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial Variations (J.N. vs J N)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "J.N. Chaney" to torrent with "J N Chaney" in automatic mode', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Infinite Crown by Terry Maggert, J N Chaney [ENG / M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Infinite Crown',
|
||||
author: 'J.N. Chaney',
|
||||
}, true); // requireAuthor: true (automatic mode)
|
||||
|
||||
// "J.N. Chaney" should normalize to "j n chaney"
|
||||
// Torrent title should normalize to include "j n chaney"
|
||||
// Author check should PASS
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
expect(breakdown.totalScore).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('matches author with periods to space-separated initials', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title by J K Rowling [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'J.K. Rowling',
|
||||
}, true);
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CamelCase and Punctuation Separator Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches CamelCase torrent title "VirginaEvans TheCorrespondent" to "The Correspondent" by "Virginia Evans"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'VirginaEvans TheCorrespondent',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Correspondent',
|
||||
author: 'Virginia Evans',
|
||||
}, false); // requireAuthor: false - source has typo "Virgina" vs "Virginia"
|
||||
|
||||
// Should match after CamelCase normalization
|
||||
// "VirginaEvans TheCorrespondent" → "virgina evans the correspondent"
|
||||
// "The Correspondent" → "the correspondent" → required words: ["correspondent"]
|
||||
// Coverage: "correspondent" found → passes
|
||||
// Note: Author has typo in source data ("Virgina" vs "Virginia"), so fuzzy matching gives partial credit
|
||||
expect(breakdown.matchScore).toBeGreaterThan(35);
|
||||
});
|
||||
|
||||
it('matches period-separated title "Twelve.Months-Jim.Butcher" to "Twelve Months" by "Jim Butcher"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Twelve.Months-Jim.Butcher',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Twelve Months',
|
||||
author: 'Jim Butcher',
|
||||
});
|
||||
|
||||
// Should match after punctuation normalization
|
||||
// "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
|
||||
// Full title match + author match
|
||||
expect(breakdown.matchScore).toBeGreaterThan(55);
|
||||
});
|
||||
|
||||
it('matches mixed CamelCase and punctuation "AuthorName-BookTitle.2024"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'JohnSmith-GreatBook.2024',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Great Book',
|
||||
author: 'John Smith',
|
||||
});
|
||||
|
||||
// "JohnSmith-GreatBook.2024" → "john smith great book 2024"
|
||||
// Gets good fuzzy match score (title words present, author present)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(35);
|
||||
});
|
||||
|
||||
it('matches CamelCase author with no separator "AuthorNameBookTitle"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'BrandonSandersonMistborn',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Mistborn',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// "BrandonSandersonMistborn" → "brandon sanderson mistborn"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('handles underscore separators "Author_Name_Book_Title"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane_Doe_Amazing_Story',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Amazing Story',
|
||||
author: 'Jane Doe',
|
||||
});
|
||||
|
||||
// "Jane_Doe_Amazing_Story" → "jane doe amazing story"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('preserves apostrophes in names like "O\'Brien"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: "Tim O'Brien - The Things They Carried",
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Things They Carried',
|
||||
author: "Tim O'Brien",
|
||||
});
|
||||
|
||||
// Apostrophe should be preserved
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('handles real-world NZB title format with periods', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Berlin Diary',
|
||||
author: 'William L. Shirer',
|
||||
});
|
||||
|
||||
// "William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs" → "william l shirer berlin diary audiobook 96kbs"
|
||||
// Gets partial score from fuzzy matching (title words + author words present)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy API Compatibility', () => {
|
||||
it('supports legacy rankTorrents signature with separate parameters', () => {
|
||||
const torrent = {
|
||||
|
||||
Reference in New Issue
Block a user