mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add interactive ebook search & selection
Introduce interactive ebook support: adds two API endpoints to search (interactive-search-ebook) and create/select ebook requests (select-ebook), plus server-side handlers to route Anna's Archive (direct) and indexer (torrent/NZB) downloads. Frontend: extend RequestActionsDropdown and InteractiveTorrentSearchModal to support an "ebook" search mode and selection flow, and add hooks (useInteractiveSearchEbook / useSelectEbook). Settings: add ebook_auto_grab_enabled with UI toggle and enforce disabling when no ebook sources are enabled; settings GET/PUT updated to persist the flag (default = true to preserve behavior). Documentation updated (scheduler, ebook-sidecar, settings pages) and ranking algorithm docs/tests extended to cover ebook-related normalization and matching cases. Includes logging and ranking integration for indexer results and normalization for Anna's Archive handling.
This commit is contained in:
@@ -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)
|
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
|
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
|
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
|
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
|
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
|
## Architecture: Bull + Cron
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
|||||||
| Key | Default | Options | Description |
|
| Key | Default | Options | Description |
|
||||||
|-----|---------|---------|-------------|
|
|-----|---------|---------|-------------|
|
||||||
| `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format |
|
| `ebook_sidecar_preferred_format` | `epub` | `epub, pdf, mobi, azw3, any` | Preferred format |
|
||||||
|
| `ebook_auto_grab_enabled` | `true` | `true, false` | Auto-create ebook requests after audiobook downloads |
|
||||||
|
|
||||||
|
*Note: Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.*
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Intelligent Ranking Algorithm
|
# Intelligent Ranking Algorithm
|
||||||
|
|
||||||
**Status:** ✅ Implemented | Comprehensive edge case test coverage
|
**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.
|
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)**
|
- ✅ **Author presence check (10 tests)**
|
||||||
- ✅ **Context-aware filtering (3 tests)**
|
- ✅ **Context-aware filtering (3 tests)**
|
||||||
- ✅ **API compatibility (2 tests)**
|
- ✅ **API compatibility (2 tests)**
|
||||||
|
- ✅ **CamelCase and punctuation separator handling (7 tests)**
|
||||||
|
|
||||||
**Tested edge cases prevent regressions from previous tweaks:**
|
**Tested edge cases prevent regressions from previous tweaks:**
|
||||||
- "We Are Legion (We Are Bob)" matching with/without subtitle
|
- "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**
|
**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:**
|
**Multi-Stage Matching:**
|
||||||
|
|
||||||
**Stage 1: Word Coverage Filter (MANDATORY)**
|
**Stage 1: Word Coverage Filter (MANDATORY)**
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ src/app/admin/settings/
|
|||||||
|
|
||||||
3. **General Settings Section** (visible when any source enabled)
|
3. **General Settings Section** (visible when any source enabled)
|
||||||
- Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any
|
- Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any
|
||||||
|
- Auto-grab toggle: Automatically create ebook requests after audiobook downloads
|
||||||
|
|
||||||
**Configuration Keys:**
|
**Configuration Keys:**
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
@@ -97,6 +98,7 @@ src/app/admin/settings/
|
|||||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive |
|
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive |
|
||||||
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr |
|
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr |
|
||||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
| `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_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
||||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ src/app/admin/settings/
|
|||||||
- If Anna's Archive enabled → Searches Anna's Archive first
|
- 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 Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled
|
||||||
- If both disabled → Ebook downloads completely off
|
- If both disabled → Ebook downloads completely off
|
||||||
|
- If auto-grab disabled → Manual "Fetch Ebook" button only (admin buttons still work)
|
||||||
|
|
||||||
## Indexer Categories (Tabbed)
|
## Indexer Categories (Tabbed)
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function RequestActionsDropdown({
|
|||||||
}: RequestActionsDropdownProps) {
|
}: RequestActionsDropdownProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||||
|
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||||
|
|
||||||
// Determine request type
|
// Determine request type
|
||||||
@@ -80,7 +81,7 @@ export function RequestActionsDropdown({
|
|||||||
const canViewSource = !!viewSourceUrl &&
|
const canViewSource = !!viewSourceUrl &&
|
||||||
['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||||
|
|
||||||
// "Try to fetch Ebook" only for audiobook requests
|
// Ebook actions (Grab Ebook, Interactive Search Ebook) only for audiobook requests
|
||||||
const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
@@ -114,6 +115,11 @@ export function RequestActionsDropdown({
|
|||||||
setShowInteractiveSearch(true);
|
setShowInteractiveSearch(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInteractiveSearchEbook = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setShowInteractiveSearchEbook(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||||
@@ -224,7 +230,7 @@ export function RequestActionsDropdown({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fetch E-book */}
|
{/* Grab E-book (automatic) */}
|
||||||
{canFetchEbook && (
|
{canFetchEbook && (
|
||||||
<button
|
<button
|
||||||
onClick={handleFetchEbook}
|
onClick={handleFetchEbook}
|
||||||
@@ -244,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"
|
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>
|
</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -332,7 +362,7 @@ export function RequestActionsDropdown({
|
|||||||
{/* Dropdown menu (rendered via portal) */}
|
{/* Dropdown menu (rendered via portal) */}
|
||||||
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
|
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
|
||||||
|
|
||||||
{/* Interactive Search Modal */}
|
{/* Interactive Search Modal (Audiobook) */}
|
||||||
<InteractiveTorrentSearchModal
|
<InteractiveTorrentSearchModal
|
||||||
isOpen={showInteractiveSearch}
|
isOpen={showInteractiveSearch}
|
||||||
onClose={() => setShowInteractiveSearch(false)}
|
onClose={() => setShowInteractiveSearch(false)}
|
||||||
@@ -342,6 +372,18 @@ export function RequestActionsDropdown({
|
|||||||
author: request.author,
|
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"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface EbookSettings {
|
|||||||
flaresolverrUrl: string;
|
flaresolverrUrl: string;
|
||||||
// General settings (shared across sources)
|
// General settings (shared across sources)
|
||||||
preferredFormat: string;
|
preferredFormat: string;
|
||||||
|
autoGrabEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -231,6 +231,29 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
|
|||||||
EPUB is recommended for most e-readers. "Any format" accepts the first available.
|
EPUB is recommended for most e-readers. "Any format" accepts the first available.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
|||||||
format: ebook.preferredFormat || 'epub',
|
format: ebook.preferredFormat || 'epub',
|
||||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||||
|
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
try {
|
||||||
// Parse request body - new structure with separate source toggles
|
// Parse request body - new structure with separate source toggles
|
||||||
const { annasArchiveEnabled, indexerSearchEnabled, format, baseUrl, flaresolverrUrl } = await request.json();
|
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
|
// Validate format
|
||||||
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
|
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
|
||||||
@@ -66,6 +69,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
category: 'ebook',
|
category: 'ebook',
|
||||||
description: 'Preferred e-book format',
|
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
|
// Anna's Archive specific settings
|
||||||
{
|
{
|
||||||
key: 'ebook_sidecar_base_url',
|
key: 'ebook_sidecar_base_url',
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
|
|||||||
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
|
||||||
// General settings
|
// General settings
|
||||||
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
|
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: {
|
general: {
|
||||||
appName: configMap.get('app_name') || 'ReadMeABook',
|
appName: configMap.get('app_name') || 'ReadMeABook',
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Interactive Torrent Search Modal
|
* Component: Interactive Torrent Search Modal
|
||||||
* Documentation: documentation/phase3/prowlarr.md
|
* 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';
|
'use client';
|
||||||
@@ -10,7 +14,14 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
||||||
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
|
import {
|
||||||
|
useInteractiveSearch,
|
||||||
|
useSelectTorrent,
|
||||||
|
useSearchTorrents,
|
||||||
|
useRequestWithTorrent,
|
||||||
|
useInteractiveSearchEbook,
|
||||||
|
useSelectEbook,
|
||||||
|
} from '@/lib/hooks/useRequests';
|
||||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||||
|
|
||||||
interface InteractiveTorrentSearchModalProps {
|
interface InteractiveTorrentSearchModalProps {
|
||||||
@@ -23,6 +34,7 @@ interface InteractiveTorrentSearchModalProps {
|
|||||||
};
|
};
|
||||||
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InteractiveTorrentSearchModal({
|
export function InteractiveTorrentSearchModal({
|
||||||
@@ -32,8 +44,9 @@ export function InteractiveTorrentSearchModal({
|
|||||||
audiobook,
|
audiobook,
|
||||||
fullAudiobook,
|
fullAudiobook,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
searchMode = 'audiobook',
|
||||||
}: InteractiveTorrentSearchModalProps) {
|
}: InteractiveTorrentSearchModalProps) {
|
||||||
// Hooks for existing request flow
|
// Hooks for existing audiobook request flow
|
||||||
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
|
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
|
||||||
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
|
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
|
||||||
|
|
||||||
@@ -41,17 +54,30 @@ export function InteractiveTorrentSearchModal({
|
|||||||
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
|
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
|
||||||
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
|
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
|
||||||
|
|
||||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]);
|
// Hooks for ebook flow
|
||||||
|
const { searchEbooks, isLoading: isSearchingEbooks, error: searchEbooksError } = useInteractiveSearchEbook();
|
||||||
|
const { selectEbook, isLoading: isSelectingEbook, error: selectEbookError } = useSelectEbook();
|
||||||
|
|
||||||
|
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]);
|
||||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||||
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
||||||
|
|
||||||
// Determine which mode we're in
|
// Determine which mode we're in
|
||||||
|
const isEbookMode = searchMode === 'ebook';
|
||||||
const hasRequestId = !!requestId;
|
const hasRequestId = !!requestId;
|
||||||
const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook;
|
|
||||||
const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent;
|
// Loading/error state based on mode
|
||||||
const error = hasRequestId
|
const isSearching = isEbookMode
|
||||||
? (searchByRequestError || selectTorrentError)
|
? isSearchingEbooks
|
||||||
: (searchByAudiobookError || requestWithTorrentError);
|
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
|
||||||
|
const isDownloading = isEbookMode
|
||||||
|
? isSelectingEbook
|
||||||
|
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
|
||||||
|
const error = isEbookMode
|
||||||
|
? (searchEbooksError || selectEbookError)
|
||||||
|
: (hasRequestId
|
||||||
|
? (searchByRequestError || selectTorrentError)
|
||||||
|
: (searchByAudiobookError || requestWithTorrentError));
|
||||||
|
|
||||||
// Reset search title when modal opens/closes or audiobook changes
|
// Reset search title when modal opens/closes or audiobook changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -72,12 +98,20 @@ export function InteractiveTorrentSearchModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let data;
|
let data;
|
||||||
if (hasRequestId) {
|
if (isEbookMode) {
|
||||||
// Existing flow: search by requestId with optional custom title
|
// Ebook mode: search Anna's Archive + indexers
|
||||||
|
if (!requestId) {
|
||||||
|
console.error('Ebook search requires a requestId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||||
|
data = await searchEbooks(requestId, customTitle);
|
||||||
|
} else if (hasRequestId) {
|
||||||
|
// Existing audiobook flow: search by requestId with optional custom title
|
||||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||||
data = await searchByRequestId(requestId, customTitle);
|
data = await searchByRequestId(requestId, customTitle);
|
||||||
} else {
|
} else {
|
||||||
// New flow: search by custom title + original author + optional ASIN for size scoring
|
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
|
||||||
const asin = fullAudiobook?.asin;
|
const asin = fullAudiobook?.asin;
|
||||||
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
|
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
|
||||||
}
|
}
|
||||||
@@ -102,11 +136,17 @@ export function InteractiveTorrentSearchModal({
|
|||||||
if (!confirmTorrent) return;
|
if (!confirmTorrent) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (hasRequestId) {
|
if (isEbookMode) {
|
||||||
// Existing flow: select torrent for existing request
|
// Ebook flow: select ebook for existing audiobook request
|
||||||
|
if (!requestId) {
|
||||||
|
throw new Error('Request ID required for ebook selection');
|
||||||
|
}
|
||||||
|
await selectEbook(requestId, confirmTorrent);
|
||||||
|
} else if (hasRequestId) {
|
||||||
|
// Existing audiobook flow: select torrent for existing request
|
||||||
await selectTorrent(requestId, confirmTorrent);
|
await selectTorrent(requestId, confirmTorrent);
|
||||||
} else {
|
} else {
|
||||||
// New flow: create request with torrent
|
// New audiobook flow: create request with torrent
|
||||||
if (!fullAudiobook) {
|
if (!fullAudiobook) {
|
||||||
throw new Error('Audiobook data required to create request');
|
throw new Error('Audiobook data required to create request');
|
||||||
}
|
}
|
||||||
@@ -120,7 +160,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
// Request list will auto-refresh via SWR
|
// Request list will auto-refresh via SWR
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error already handled by hook
|
// Error already handled by hook
|
||||||
console.error('Failed to download torrent:', err);
|
console.error('Failed to download:', err);
|
||||||
setConfirmTorrent(null);
|
setConfirmTorrent(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -138,14 +178,26 @@ export function InteractiveTorrentSearchModal({
|
|||||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
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 (
|
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">
|
<div className="space-y-4">
|
||||||
{/* Search customization - editable for ALL modes */}
|
{/* Search customization - editable for ALL modes */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
|
<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">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Search Title
|
{searchLabel}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -153,7 +205,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
value={searchTitle}
|
value={searchTitle}
|
||||||
onChange={(e) => setSearchTitle(e.target.value)}
|
onChange={(e) => setSearchTitle(e.target.value)}
|
||||||
onKeyPress={handleSearchKeyPress}
|
onKeyPress={handleSearchKeyPress}
|
||||||
placeholder="Enter book title to search..."
|
placeholder={searchPlaceholder}
|
||||||
disabled={isSearching}
|
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"
|
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 +232,14 @@ export function InteractiveTorrentSearchModal({
|
|||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No results */}
|
{/* No results */}
|
||||||
{!isSearching && results.length === 0 && (
|
{!isSearching && results.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<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">
|
<Button onClick={performSearch} variant="outline" className="mt-4">
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,7 +272,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
Seeds
|
Seeds
|
||||||
</th>
|
</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">
|
<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>
|
||||||
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
|
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
|
||||||
Action
|
Action
|
||||||
@@ -246,21 +298,30 @@ export function InteractiveTorrentSearchModal({
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-1 flex-wrap">
|
<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 && (
|
{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}
|
{result.format}
|
||||||
</span>
|
</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">
|
<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)}
|
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
|
||||||
</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
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
|
<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>
|
||||||
<td className="px-2 py-3 whitespace-nowrap text-sm">
|
<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))}`}>
|
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
|
||||||
@@ -271,15 +332,23 @@ export function InteractiveTorrentSearchModal({
|
|||||||
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
|
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
|
<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">
|
{isEbookMode && result.source === 'annas_archive' ? (
|
||||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
<span className="text-gray-400">N/A</span>
|
||||||
<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>
|
<span className="flex items-center gap-1">
|
||||||
{result.seeders}
|
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
</span>
|
<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>
|
||||||
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
|
<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>
|
||||||
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
|
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
|
||||||
<Button
|
<Button
|
||||||
@@ -303,7 +372,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
{!isSearching && results.length > 0 && (
|
{!isSearching && results.length > 0 && (
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
<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">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Found {results.length} torrent{results.length !== 1 ? 's' : ''}
|
{resultCountText(results.length)}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={performSearch} variant="outline" size="sm">
|
<Button onClick={performSearch} variant="outline" size="sm">
|
||||||
Refresh Results
|
Refresh Results
|
||||||
@@ -318,7 +387,7 @@ export function InteractiveTorrentSearchModal({
|
|||||||
isOpen={!!confirmTorrent}
|
isOpen={!!confirmTorrent}
|
||||||
onClose={() => setConfirmTorrent(null)}
|
onClose={() => setConfirmTorrent(null)}
|
||||||
onConfirm={handleConfirmDownload}
|
onConfirm={handleConfirmDownload}
|
||||||
title="Download Torrent"
|
title={confirmTitle}
|
||||||
message={`Download "${confirmTorrent?.title}"?`}
|
message={`Download "${confirmTorrent?.title}"?`}
|
||||||
confirmText="Download"
|
confirmText="Download"
|
||||||
isLoading={isDownloading}
|
isLoading={isDownloading}
|
||||||
|
|||||||
@@ -397,3 +397,88 @@ export function useRequestWithTorrent() {
|
|||||||
|
|
||||||
return { requestWithTorrent, isLoading, error };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Component: Monitor RSS Feeds Processor
|
* Component: Monitor RSS Feeds Processor
|
||||||
* Documentation: documentation/backend/services/scheduler.md
|
* 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';
|
import { prisma } from '../db';
|
||||||
@@ -57,11 +57,10 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
|||||||
return { success: true, message: 'No RSS results', matched: 0 };
|
return { success: true, message: 'No RSS results', matched: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all active audiobook requests awaiting search (missing audiobooks)
|
// Get all active requests awaiting search (audiobooks and ebooks)
|
||||||
// Note: RSS feeds are for torrents, so only audiobook requests are matched
|
// Both types can be matched against RSS torrent feeds
|
||||||
const missingRequests = await prisma.request.findMany({
|
const missingRequests = await prisma.request.findMany({
|
||||||
where: {
|
where: {
|
||||||
type: 'audiobook', // Only audiobook requests (RSS feeds are for torrents)
|
|
||||||
status: 'awaiting_search',
|
status: 'awaiting_search',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
@@ -75,7 +74,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
|||||||
return { success: true, message: 'No missing requests', matched: 0 };
|
return { success: true, message: 'No missing requests', matched: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match RSS results against missing audiobooks
|
// Match RSS results against missing requests
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
|
|
||||||
@@ -96,16 +95,27 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
|||||||
if (hasAuthor && titleMatchCount >= 2) {
|
if (hasAuthor && titleMatchCount >= 2) {
|
||||||
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
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 {
|
try {
|
||||||
await jobQueue.addSearchJob(request.id, {
|
if (request.type === 'ebook') {
|
||||||
id: audiobook.id,
|
await jobQueue.addSearchEbookJob(request.id, {
|
||||||
title: audiobook.title,
|
id: audiobook.id,
|
||||||
author: audiobook.author,
|
title: audiobook.title,
|
||||||
asin: audiobook.audibleAsin || undefined,
|
author: audiobook.author,
|
||||||
});
|
asin: audiobook.audibleAsin || undefined,
|
||||||
matched++;
|
});
|
||||||
logger.info(`Triggered search job for request ${request.id}`);
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -785,8 +785,16 @@ async function createEbookRequestIfEnabled(
|
|||||||
logger: RMABLogger
|
logger: RMABLogger
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Check which ebook sources are enabled
|
|
||||||
const configService = getConfigService();
|
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 annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled');
|
||||||
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled');
|
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled');
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,9 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
|
|||||||
logger.info('Starting retry job for requests awaiting search...');
|
logger.info('Starting retry job for requests awaiting search...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find all active audiobook requests in awaiting_search status
|
// Find all active requests (audiobook or ebook) in awaiting_search status
|
||||||
// Note: Ebook requests have separate search mechanism (search_ebook job)
|
|
||||||
const requests = await prisma.request.findMany({
|
const requests = await prisma.request.findMany({
|
||||||
where: {
|
where: {
|
||||||
type: 'audiobook', // Only audiobook requests (ebooks use different search)
|
|
||||||
status: 'awaiting_search',
|
status: 'awaiting_search',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
@@ -45,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();
|
const jobQueue = getJobQueueService();
|
||||||
let triggered = 0;
|
let triggered = 0;
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
try {
|
try {
|
||||||
await jobQueue.addSearchJob(request.id, {
|
if (request.type === 'ebook') {
|
||||||
id: request.audiobook.id,
|
// Ebook requests use ebook search (Anna's Archive, etc.)
|
||||||
title: request.audiobook.title,
|
await jobQueue.addSearchEbookJob(request.id, {
|
||||||
author: request.audiobook.author,
|
id: request.audiobook.id,
|
||||||
asin: request.audiobook.audibleAsin || undefined,
|
title: request.audiobook.title,
|
||||||
});
|
author: request.audiobook.author,
|
||||||
triggered++;
|
asin: request.audiobook.audibleAsin || undefined,
|
||||||
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
|
});
|
||||||
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export interface RankedEbookTorrent extends TorrentResult {
|
|||||||
finalScore: number; // score + bonusPoints
|
finalScore: number; // score + bonusPoints
|
||||||
rank: number;
|
rank: number;
|
||||||
breakdown: EbookScoreBreakdown;
|
breakdown: EbookScoreBreakdown;
|
||||||
|
ebookFormat?: string; // Detected ebook format (epub, pdf, mobi, etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RankingAlgorithm {
|
export class RankingAlgorithm {
|
||||||
@@ -330,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)
|
* Score title/author match quality (60 points max)
|
||||||
* Title similarity: 0-45 points (heavily weighted!)
|
* Title similarity: 0-45 points (heavily weighted!)
|
||||||
@@ -340,10 +361,22 @@ export class RankingAlgorithm {
|
|||||||
audiobook: AudiobookRequest,
|
audiobook: AudiobookRequest,
|
||||||
requireAuthor: boolean = true
|
requireAuthor: boolean = true
|
||||||
): number {
|
): number {
|
||||||
// Normalize whitespace (multiple spaces → single space) for consistent matching
|
// Normalize for matching (handles CamelCase, punctuation separators)
|
||||||
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
const torrentTitle = this.normalizeForMatching(torrent.title);
|
||||||
const requestTitle = audiobook.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
const requestTitle = this.normalizeForMatching(audiobook.title);
|
||||||
const requestAuthor = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
||||||
|
// 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) ==========
|
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
|
||||||
// Extract significant words (filter out common stop words)
|
// Extract significant words (filter out common stop words)
|
||||||
@@ -351,26 +384,37 @@ export class RankingAlgorithm {
|
|||||||
|
|
||||||
const extractWords = (text: string, stopList: string[]): string[] => {
|
const extractWords = (text: string, stopList: string[]): string[] => {
|
||||||
return text
|
return text
|
||||||
|
// Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent"
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
.toLowerCase()
|
.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+/)
|
.split(/\s+/)
|
||||||
.filter(word => word.length > 0 && !stopList.includes(word));
|
.filter(word => word.length > 0 && !stopList.includes(word));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Separate required words (outside parentheses/brackets) from optional words (inside)
|
// Separate required words (outside parentheses/brackets) from optional words (inside)
|
||||||
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
|
// 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 } => {
|
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
|
// Extract content in parentheses/brackets as optional
|
||||||
const optionalPattern = /[(\[{]([^)\]}]+)[)\]}]/g;
|
const optionalPattern = /[(\[{]([^)\]}]+)[)\]}]/g;
|
||||||
const optionalMatches: string[] = [];
|
const optionalMatches: string[] = [];
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
while ((match = optionalPattern.exec(title)) !== null) {
|
while ((match = optionalPattern.exec(originalTitle)) !== null) {
|
||||||
optionalMatches.push(match[1]);
|
optionalMatches.push(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove parenthetical/bracketed content to get required portion
|
// 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(' ');
|
const optional = optionalMatches.join(' ');
|
||||||
|
|
||||||
return { required, optional };
|
return { required, optional };
|
||||||
@@ -400,7 +444,7 @@ export class RankingAlgorithm {
|
|||||||
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
|
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
|
||||||
// Only enforced in automatic mode (requireAuthor: true)
|
// Only enforced in automatic mode (requireAuthor: true)
|
||||||
// Interactive search (requireAuthor: false) shows all results
|
// 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
|
// No high-confidence author match → reject to prevent wrong-author matches
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -408,6 +452,10 @@ export class RankingAlgorithm {
|
|||||||
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
||||||
let titleScore = 0;
|
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)
|
// Try matching with full title first, then fall back to required title (without parentheses)
|
||||||
const titlesToTry = [requestTitle];
|
const titlesToTry = [requestTitle];
|
||||||
if (requiredTitle !== requestTitle) {
|
if (requiredTitle !== requestTitle) {
|
||||||
@@ -422,20 +470,37 @@ export class RankingAlgorithm {
|
|||||||
const beforeTitle = torrentTitle.substring(0, titleIndex);
|
const beforeTitle = torrentTitle.substring(0, titleIndex);
|
||||||
const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length);
|
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
|
// Extract significant words BEFORE the matched title
|
||||||
const beforeWords = extractWords(beforeTitle, stopWords);
|
const beforeWords = extractWords(beforeTitle, stopWords);
|
||||||
|
|
||||||
// Title is complete if:
|
// Title is complete if:
|
||||||
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
|
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
|
||||||
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
|
// 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 ', ' - ', ' [', ' (', ' {', ' :', ','];
|
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
|
||||||
|
|
||||||
// Check if afterTitle starts with author name (handles space-separated format like "Title Author Year")
|
// Check if afterTitle starts with any author name (handles space-separated format like "Title Author Year")
|
||||||
const afterStartsWithAuthor = requestAuthor.length > 2 &&
|
const afterStartsWithAuthor = normalizedAuthors.some(author =>
|
||||||
afterTitle.trim().startsWith(requestAuthor);
|
author.length > 2 && afterTitle.trim().startsWith(author)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check metadata markers in both normalized and original suffixes
|
||||||
const hasMetadataSuffix = afterTitle === '' ||
|
const hasMetadataSuffix = afterTitle === '' ||
|
||||||
metadataMarkers.some(marker => afterTitle.startsWith(marker)) ||
|
metadataMarkers.some(marker => afterTitle.startsWith(marker)) ||
|
||||||
|
metadataMarkers.some(marker => afterTitleOriginal.startsWith(marker)) ||
|
||||||
afterStartsWithAuthor;
|
afterStartsWithAuthor;
|
||||||
|
|
||||||
// Check prefix validity:
|
// Check prefix validity:
|
||||||
@@ -446,16 +511,32 @@ export class RankingAlgorithm {
|
|||||||
|
|
||||||
// Check if title is immediately preceded by a metadata separator
|
// Check if title is immediately preceded by a metadata separator
|
||||||
// This handles "Author - Series - 01 - Title" patterns
|
// This handles "Author - Series - 01 - Title" patterns
|
||||||
|
// Check both normalized and original strings for separators
|
||||||
const precedingText = beforeTitle.trimEnd();
|
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 =
|
const titlePrecededBySeparator =
|
||||||
precedingText.endsWith('-') ||
|
precedingText.endsWith('-') ||
|
||||||
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
|
// This handles "Author Name - Title" patterns
|
||||||
const authorInPrefix = requestAuthor.length > 2 &&
|
const authorInPrefix = normalizedAuthors.some(author =>
|
||||||
beforeTitle.includes(requestAuthor);
|
author.length > 2 && beforeTitle.includes(author)
|
||||||
|
);
|
||||||
|
|
||||||
const hasAcceptablePrefix =
|
const hasAcceptablePrefix =
|
||||||
hasNoWordsPrefix ||
|
hasNoWordsPrefix ||
|
||||||
@@ -481,24 +562,18 @@ export class RankingAlgorithm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
|
// ========== 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)
|
// Check how many authors appear in torrent title (exact substring match)
|
||||||
const authorMatches = requestAuthors.filter(author =>
|
const authorMatches = normalizedAuthors.filter(author =>
|
||||||
torrentTitle.includes(author)
|
torrentTitle.includes(author)
|
||||||
);
|
);
|
||||||
|
|
||||||
let authorScore = 0;
|
let authorScore = 0;
|
||||||
if (authorMatches.length > 0) {
|
if (authorMatches.length > 0) {
|
||||||
// Exact substring match → proportional credit
|
// Exact substring match → proportional credit
|
||||||
authorScore = (authorMatches.length / requestAuthors.length) * 15;
|
authorScore = (authorMatches.length / normalizedAuthors.length) * 15;
|
||||||
} else {
|
} else {
|
||||||
// No exact match → use fuzzy similarity for partial credit
|
// 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);
|
return Math.min(60, titleScore + authorScore);
|
||||||
@@ -506,22 +581,16 @@ export class RankingAlgorithm {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if author is present in torrent title with high confidence
|
* 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 torrentTitle - Normalized torrent title (already processed by normalizeForMatching)
|
||||||
* @param requestAuthor - Normalized author name (lowercase)
|
* @param normalizedAuthors - Array of normalized author names (roles already filtered)
|
||||||
* @returns true if at least ONE author is present with high confidence
|
* @returns true if at least ONE author is present with high confidence
|
||||||
*/
|
*/
|
||||||
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
|
private checkAuthorPresenceWithParsed(torrentTitle: string, normalizedAuthors: 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));
|
|
||||||
|
|
||||||
// At least ONE author must match with high confidence
|
// At least ONE author must match with high confidence
|
||||||
return authors.some(author => {
|
return normalizedAuthors.some(author => {
|
||||||
// Check 1: Exact substring match
|
// Check 1: Exact substring match (works well now that both are normalized)
|
||||||
if (torrentTitle.includes(author)) {
|
if (torrentTitle.includes(author)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -537,6 +606,7 @@ export class RankingAlgorithm {
|
|||||||
// Check 3: Core name components (first + last name present within 30 chars)
|
// Check 3: Core name components (first + last name present within 30 chars)
|
||||||
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
|
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
|
||||||
// Handles: "Brandon R. Sanderson" 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);
|
const words = author.split(/\s+/).filter(w => w.length > 1);
|
||||||
if (words.length >= 2) {
|
if (words.length >= 2) {
|
||||||
const firstName = words[0];
|
const firstName = words[0];
|
||||||
@@ -558,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
|
* Detect format from torrent title
|
||||||
*/
|
*/
|
||||||
@@ -687,6 +778,9 @@ export class RankingAlgorithm {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ranked = filteredTorrents.map((torrent) => {
|
const ranked = filteredTorrents.map((torrent) => {
|
||||||
|
// Detect ebook format from title
|
||||||
|
const detectedFormat = this.detectEbookFormat(torrent);
|
||||||
|
|
||||||
// Calculate base scores (0-100)
|
// Calculate base scores (0-100)
|
||||||
// Reuse scoreMatch and scoreSeeders from audiobook ranking
|
// Reuse scoreMatch and scoreSeeders from audiobook ranking
|
||||||
const formatScore = this.scoreEbookFormat(torrent, ebook.preferredFormat);
|
const formatScore = this.scoreEbookFormat(torrent, ebook.preferredFormat);
|
||||||
@@ -765,6 +859,7 @@ export class RankingAlgorithm {
|
|||||||
notes: [],
|
notes: [],
|
||||||
}, ebook.preferredFormat),
|
}, ebook.preferredFormat),
|
||||||
},
|
},
|
||||||
|
ebookFormat: detectedFormat !== 'unknown' ? detectedFormat : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -824,19 +919,27 @@ export class RankingAlgorithm {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect ebook format from torrent title
|
* Detect ebook format from torrent title
|
||||||
|
* Handles formats in various positions: .epub, (epub), [epub], " epub"
|
||||||
*/
|
*/
|
||||||
private detectEbookFormat(torrent: TorrentResult): string {
|
private detectEbookFormat(torrent: TorrentResult): string {
|
||||||
const title = torrent.title.toLowerCase();
|
const title = torrent.title.toLowerCase();
|
||||||
|
|
||||||
// Check for common ebook format extensions/keywords
|
// Check for common ebook format extensions/keywords
|
||||||
if (title.includes('.epub') || title.includes(' epub')) return 'epub';
|
// Patterns: .format, (format), [format], " format", "_format"
|
||||||
if (title.includes('.pdf') || title.includes(' pdf')) return 'pdf';
|
const formats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr'];
|
||||||
if (title.includes('.mobi') || title.includes(' mobi')) return 'mobi';
|
|
||||||
if (title.includes('.azw3') || title.includes(' azw3')) return 'azw3';
|
for (const format of formats) {
|
||||||
if (title.includes('.azw') || title.includes(' azw')) return 'azw';
|
if (
|
||||||
if (title.includes('.fb2') || title.includes(' fb2')) return 'fb2';
|
title.includes(`.${format}`) || // file.epub
|
||||||
if (title.includes('.cbz') || title.includes(' cbz')) return 'cbz';
|
title.includes(`(${format})`) || // (epub)
|
||||||
if (title.includes('.cbr') || title.includes(' cbr')) return 'cbr';
|
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
|
// Default to unknown
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
|
|||||||
@@ -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', () => {
|
describe('Legacy API Compatibility', () => {
|
||||||
it('supports legacy rankTorrents signature with separate parameters', () => {
|
it('supports legacy rankTorrents signature with separate parameters', () => {
|
||||||
const torrent = {
|
const torrent = {
|
||||||
|
|||||||
Reference in New Issue
Block a user