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:
kikootwo
2026-02-02 19:59:58 -05:00
parent c913be5ca2
commit 1afab5d47f
19 changed files with 1339 additions and 115 deletions
+2 -2
View File
@@ -18,10 +18,10 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup)
2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default
3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), enabled by default
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (limit 100), triggers search jobs for matches, enabled by default
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default
## Architecture: Bull + Cron
@@ -65,6 +65,9 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
| Key | Default | Options | Description |
|-----|---------|---------|-------------|
| `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
+14 -1
View File
@@ -1,7 +1,7 @@
# Intelligent Ranking Algorithm
**Status:** ✅ Implemented | Comprehensive edge case test coverage
**Tests:** tests/utils/ranking-algorithm.test.ts (73 test cases)
**Tests:** tests/utils/ranking-algorithm.test.ts (80+ test cases)
Evaluates and scores torrents to automatically select best audiobook download.
@@ -19,6 +19,7 @@ Evaluates and scores torrents to automatically select best audiobook download.
-**Author presence check (10 tests)**
-**Context-aware filtering (3 tests)**
-**API compatibility (2 tests)**
-**CamelCase and punctuation separator handling (7 tests)**
**Tested edge cases prevent regressions from previous tweaks:**
- "We Are Legion (We Are Bob)" matching with/without subtitle
@@ -35,6 +36,18 @@ Evaluates and scores torrents to automatically select best audiobook download.
**1. Title/Author Match (60 pts max) - MOST IMPORTANT**
**Pre-Processing: Text Normalization**
- All titles and author names are normalized before matching
- **CamelCase splitting:** `"TheCorrespondent"``"the correspondent"`
- **Punctuation to spaces:** `"Twelve.Months-Jim"``"twelve months jim"`
- **Preserves apostrophes:** `"O'Brien"` remains `"o'brien"`
- Handles common indexer naming patterns (NZB, torrent scene releases)
**Examples of normalization:**
- `"VirginaEvans TheCorrespondent"``"virgina evans the correspondent"`
- `"Twelve.Months-Jim.Butcher"``"twelve months jim butcher"`
- `"Author_Name-Book.Title.2024"``"author name book title 2024"`
**Multi-Stage Matching:**
**Stage 1: Word Coverage Filter (MANDATORY)**
+3
View File
@@ -90,6 +90,7 @@ src/app/admin/settings/
3. **General Settings Section** (visible when any source enabled)
- Preferred format: EPUB (recommended), PDF, MOBI, AZW3, Any
- Auto-grab toggle: Automatically create ebook requests after audiobook downloads
**Configuration Keys:**
| Key | Default | Description |
@@ -97,6 +98,7 @@ src/app/admin/settings/
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive |
| `ebook_indexer_search_enabled` | `false` | Enable Indexer Search via Prowlarr |
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
@@ -104,6 +106,7 @@ src/app/admin/settings/
- If Anna's Archive enabled → Searches Anna's Archive first
- If Indexer Search enabled → Falls back to indexer search if Anna's Archive fails/disabled
- If both disabled → Ebook downloads completely off
- If auto-grab disabled → Manual "Fetch Ebook" button only (admin buttons still work)
## Indexer Categories (Tabbed)
@@ -40,6 +40,7 @@ export function RequestActionsDropdown({
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine request type
@@ -80,7 +81,7 @@ export function RequestActionsDropdown({
const canViewSource = !!viewSourceUrl &&
['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);
// Close dropdown when clicking outside
@@ -114,6 +115,11 @@ export function RequestActionsDropdown({
setShowInteractiveSearch(true);
};
const handleInteractiveSearchEbook = () => {
setIsOpen(false);
setShowInteractiveSearchEbook(true);
};
const handleCancel = async () => {
setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
@@ -224,7 +230,7 @@ export function RequestActionsDropdown({
</a>
)}
{/* Fetch E-book */}
{/* Grab E-book (automatic) */}
{canFetchEbook && (
<button
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"
/>
</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>
)}
@@ -332,7 +362,7 @@ export function RequestActionsDropdown({
{/* Dropdown menu (rendered via portal) */}
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
{/* Interactive Search Modal */}
{/* Interactive Search Modal (Audiobook) */}
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearch}
onClose={() => setShowInteractiveSearch(false)}
@@ -342,6 +372,18 @@ export function RequestActionsDropdown({
author: request.author,
}}
/>
{/* Interactive Search Modal (Ebook) */}
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearchEbook}
onClose={() => setShowInteractiveSearchEbook(false)}
requestId={request.requestId}
audiobook={{
title: request.title,
author: request.author,
}}
searchMode="ebook"
/>
</>
);
}
+1
View File
@@ -114,6 +114,7 @@ export interface EbookSettings {
flaresolverrUrl: string;
// General settings (shared across sources)
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.
</p>
</div>
{/* Auto Grab Toggle */}
<div className="flex items-start gap-4 pt-2">
<input
type="checkbox"
id="auto-grab-enabled"
checked={ebook.autoGrabEnabled ?? true}
onChange={(e) => updateEbook('autoGrabEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="auto-grab-enabled"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Automatically fetch ebooks
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When enabled, ebook requests are created automatically after audiobook downloads complete.
When disabled, use the "Fetch Ebook" button on completed requests.
</p>
</div>
</div>
</div>
</div>
)}
@@ -82,6 +82,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
format: ebook.preferredFormat || 'epub',
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
flaresolverrUrl: ebook.flaresolverrUrl || '',
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
}),
});
+10 -1
View File
@@ -14,7 +14,10 @@ export async function PUT(request: NextRequest) {
return requireAdmin(req, async () => {
try {
// 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
const validFormats = ['epub', 'pdf', 'mobi', 'azw3', 'any'];
@@ -66,6 +69,12 @@ export async function PUT(request: NextRequest) {
category: 'ebook',
description: 'Preferred e-book format',
},
{
key: 'ebook_auto_grab_enabled',
value: effectiveAutoGrabEnabled ? 'true' : 'false',
category: 'ebook',
description: 'Automatically create ebook requests after audiobook downloads complete',
},
// Anna's Archive specific settings
{
key: 'ebook_sidecar_base_url',
+2
View File
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
// General settings
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
// Auto-grab: default true to preserve existing behavior
autoGrabEnabled: configMap.get('ebook_auto_grab_enabled') !== 'false',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
@@ -0,0 +1,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
* Documentation: documentation/phase3/prowlarr.md
*
* Supports two search modes:
* - audiobook: Search for audiobook torrents/NZBs (default)
* - ebook: Search for ebooks from Anna's Archive + indexers
*/
'use client';
@@ -10,7 +14,14 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
import {
useInteractiveSearch,
useSelectTorrent,
useSearchTorrents,
useRequestWithTorrent,
useInteractiveSearchEbook,
useSelectEbook,
} from '@/lib/hooks/useRequests';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface InteractiveTorrentSearchModalProps {
@@ -23,6 +34,7 @@ interface InteractiveTorrentSearchModalProps {
};
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
}
export function InteractiveTorrentSearchModal({
@@ -32,8 +44,9 @@ export function InteractiveTorrentSearchModal({
audiobook,
fullAudiobook,
onSuccess,
searchMode = 'audiobook',
}: InteractiveTorrentSearchModalProps) {
// Hooks for existing request flow
// Hooks for existing audiobook request flow
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
@@ -41,17 +54,30 @@ export function InteractiveTorrentSearchModal({
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]);
// Hooks for ebook flow
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 [searchTitle, setSearchTitle] = useState(audiobook.title);
// Determine which mode we're in
const isEbookMode = searchMode === 'ebook';
const hasRequestId = !!requestId;
const isSearching = hasRequestId ? isSearchingByRequest : isSearchingByAudiobook;
const isDownloading = hasRequestId ? isSelectingTorrent : isRequestingWithTorrent;
const error = hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError);
// Loading/error state based on mode
const isSearching = isEbookMode
? isSearchingEbooks
: (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
React.useEffect(() => {
@@ -72,12 +98,20 @@ export function InteractiveTorrentSearchModal({
try {
let data;
if (hasRequestId) {
// Existing flow: search by requestId with optional custom title
if (isEbookMode) {
// Ebook mode: search Anna's Archive + indexers
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;
data = await searchByRequestId(requestId, customTitle);
} 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;
data = await searchByAudiobook(searchTitle, audiobook.author, asin);
}
@@ -102,11 +136,17 @@ export function InteractiveTorrentSearchModal({
if (!confirmTorrent) return;
try {
if (hasRequestId) {
// Existing flow: select torrent for existing request
if (isEbookMode) {
// 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);
} else {
// New flow: create request with torrent
// New audiobook flow: create request with torrent
if (!fullAudiobook) {
throw new Error('Audiobook data required to create request');
}
@@ -120,7 +160,7 @@ export function InteractiveTorrentSearchModal({
// Request list will auto-refresh via SWR
} catch (err) {
// Error already handled by hook
console.error('Failed to download torrent:', err);
console.error('Failed to download:', err);
setConfirmTorrent(null);
}
};
@@ -138,14 +178,26 @@ export function InteractiveTorrentSearchModal({
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
};
// UI text based on mode
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent';
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title';
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
const resultCountText = (count: number) =>
isEbookMode
? `Found ${count} ebook${count !== 1 ? 's' : ''}`
: `Found ${count} torrent${count !== 1 ? 's' : ''}`;
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent';
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title="Select Torrent" size="full">
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full">
<div className="space-y-4">
{/* Search customization - editable for ALL modes */}
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Title
{searchLabel}
</label>
<div className="flex gap-2">
<input
@@ -153,7 +205,7 @@ export function InteractiveTorrentSearchModal({
value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)}
onKeyPress={handleSearchKeyPress}
placeholder="Enter book title to search..."
placeholder={searchPlaceholder}
disabled={isSearching}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
/>
@@ -180,14 +232,14 @@ export function InteractiveTorrentSearchModal({
{isSearching && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
<span className="ml-3 text-gray-600 dark:text-gray-400">Searching for torrents...</span>
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span>
</div>
)}
{/* No results */}
{!isSearching && results.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No torrents/nzbs found</p>
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p>
<Button onClick={performSearch} variant="outline" className="mt-4">
Try Again
</Button>
@@ -220,7 +272,7 @@ export function InteractiveTorrentSearchModal({
Seeds
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
Indexer
{isEbookMode ? 'Source' : 'Indexer'}
</th>
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
Action
@@ -246,21 +298,30 @@ export function InteractiveTorrentSearchModal({
</a>
</div>
<div className="flex gap-2 mt-1 flex-wrap">
{/* Anna's Archive badge for ebook mode */}
{isEbookMode && result.source === 'annas_archive' && (
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
Anna's Archive
</span>
)}
{result.format && (
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded">
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
{result.format}
</span>
)}
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
{formatSize(result.size)}
</span>
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
</span>
{/* Hide seeds badge for Anna's Archive results */}
{!(isEbookMode && result.source === 'annas_archive') && (
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
{result.seeders} seeds
</span>
)}
</div>
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
{formatSize(result.size)}
{result.size > 0 ? formatSize(result.size) : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
@@ -271,15 +332,23 @@ export function InteractiveTorrentSearchModal({
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
</td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-gray-400">N/A</span>
) : (
<span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
</svg>
{result.seeders}
</span>
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
{result.indexer}
{isEbookMode && result.source === 'annas_archive' ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
) : (
result.indexer
)}
</td>
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
<Button
@@ -303,7 +372,7 @@ export function InteractiveTorrentSearchModal({
{!isSearching && results.length > 0 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
Found {results.length} torrent{results.length !== 1 ? 's' : ''}
{resultCountText(results.length)}
</p>
<Button onClick={performSearch} variant="outline" size="sm">
Refresh Results
@@ -318,7 +387,7 @@ export function InteractiveTorrentSearchModal({
isOpen={!!confirmTorrent}
onClose={() => setConfirmTorrent(null)}
onConfirm={handleConfirmDownload}
title="Download Torrent"
title={confirmTitle}
message={`Download "${confirmTorrent?.title}"?`}
confirmText="Download"
isLoading={isDownloading}
+85
View File
@@ -397,3 +397,88 @@ export function useRequestWithTorrent() {
return { requestWithTorrent, isLoading, error };
}
export function useInteractiveSearchEbook() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchEbooks = async (requestId: string, customTitle?: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/interactive-search-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: customTitle ? JSON.stringify({ customTitle }) : undefined,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to search for ebooks');
}
return data.results || [];
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { searchEbooks, isLoading, error };
}
export function useSelectEbook() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectEbook = async (requestId: string, ebook: any) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/select-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ebook }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to download ebook');
}
// Revalidate requests
mutate((key) => typeof key === 'string' && key.includes('/api/requests'));
return data;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { selectEbook, isLoading, error };
}
@@ -2,7 +2,7 @@
* Component: Monitor RSS Feeds Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Monitors RSS feeds for new audiobook releases and matches against missing requests
* Monitors RSS feeds for new releases and matches against missing requests (audiobooks and ebooks)
*/
import { prisma } from '../db';
@@ -57,11 +57,10 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
return { success: true, message: 'No RSS results', matched: 0 };
}
// Get all active audiobook requests awaiting search (missing audiobooks)
// Note: RSS feeds are for torrents, so only audiobook requests are matched
// Get all active requests awaiting search (audiobooks and ebooks)
// Both types can be matched against RSS torrent feeds
const missingRequests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (RSS feeds are for torrents)
status: 'awaiting_search',
deletedAt: null,
},
@@ -75,7 +74,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
return { success: true, message: 'No missing requests', matched: 0 };
}
// Match RSS results against missing audiobooks
// Match RSS results against missing requests
let matched = 0;
const jobQueue = getJobQueueService();
@@ -96,16 +95,27 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
if (hasAuthor && titleMatchCount >= 2) {
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
// Trigger search job to process this request
// Trigger appropriate search job based on request type
try {
await jobQueue.addSearchJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered search job for request ${request.id}`);
if (request.type === 'ebook') {
await jobQueue.addSearchEbookJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered ebook search job for request ${request.id}`);
} else {
await jobQueue.addSearchJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered audiobook search job for request ${request.id}`);
}
} catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
@@ -785,8 +785,16 @@ async function createEbookRequestIfEnabled(
logger: RMABLogger
): Promise<void> {
try {
// Check which ebook sources are enabled
const configService = getConfigService();
// Check if auto-grab is enabled (default: true for backward compatibility)
const autoGrabEnabled = await configService.get('ebook_auto_grab_enabled');
if (autoGrabEnabled === 'false') {
logger.info('Ebook auto-grab disabled, skipping automatic ebook request creation');
return;
}
// Check which ebook sources are enabled
const annasArchiveEnabled = await configService.get('ebook_annas_archive_enabled');
const indexerSearchEnabled = await configService.get('ebook_indexer_search_enabled');
@@ -21,11 +21,9 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
logger.info('Starting retry job for requests awaiting search...');
try {
// Find all active audiobook requests in awaiting_search status
// Note: Ebook requests have separate search mechanism (search_ebook job)
// Find all active requests (audiobook or ebook) in awaiting_search status
const requests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks use different search)
status: 'awaiting_search',
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();
let triggered = 0;
for (const request of requests) {
try {
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
if (request.type === 'ebook') {
// Ebook requests use ebook search (Anna's Archive, etc.)
await jobQueue.addSearchEbookJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`);
} else {
// Audiobook requests use indexer search (Prowlarr)
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`);
}
} catch (error) {
logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
+147 -44
View File
@@ -95,6 +95,7 @@ export interface RankedEbookTorrent extends TorrentResult {
finalScore: number; // score + bonusPoints
rank: number;
breakdown: EbookScoreBreakdown;
ebookFormat?: string; // Detected ebook format (epub, pdf, mobi, etc.)
}
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)
* Title similarity: 0-45 points (heavily weighted!)
@@ -340,10 +361,22 @@ export class RankingAlgorithm {
audiobook: AudiobookRequest,
requireAuthor: boolean = true
): number {
// Normalize whitespace (multiple spaces → single space) for consistent matching
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
const requestTitle = audiobook.title.toLowerCase().replace(/\s+/g, ' ').trim();
const requestAuthor = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
// Normalize for matching (handles CamelCase, punctuation separators)
const torrentTitle = this.normalizeForMatching(torrent.title);
const requestTitle = this.normalizeForMatching(audiobook.title);
// Parse authors from RAW string first (preserving commas for splitting)
// Then normalize individual authors for matching
const requestAuthorRaw = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim();
const parsedAuthors = requestAuthorRaw
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize parsed authors for matching (handles CamelCase in author names)
const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a));
// Combined normalized author string for fuzzy matching
const requestAuthorNormalized = normalizedAuthors.join(' ');
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
// Extract significant words (filter out common stop words)
@@ -351,26 +384,37 @@ export class RankingAlgorithm {
const extractWords = (text: string, stopList: string[]): string[] => {
return text
// Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase()
.replace(/[^\w\s]/g, ' ') // Remove punctuation
// Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ')
// Remove other punctuation (but keep apostrophes for contractions)
.replace(/[^\w\s']/g, ' ')
.split(/\s+/)
.filter(word => word.length > 0 && !stopList.includes(word));
};
// Separate required words (outside parentheses/brackets) from optional words (inside)
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
// Work with original title format for bracket detection
const originalTitle = audiobook.title.toLowerCase();
// Extract content in parentheses/brackets as optional
const optionalPattern = /[(\[{]([^)\]}]+)[)\]}]/g;
const optionalMatches: string[] = [];
let match;
while ((match = optionalPattern.exec(title)) !== null) {
while ((match = optionalPattern.exec(originalTitle)) !== null) {
optionalMatches.push(match[1]);
}
// Remove parenthetical/bracketed content to get required portion
const required = title.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw);
const optional = optionalMatches.join(' ');
return { required, optional };
@@ -400,7 +444,7 @@ export class RankingAlgorithm {
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
// Only enforced in automatic mode (requireAuthor: true)
// Interactive search (requireAuthor: false) shows all results
if (requireAuthor && !this.checkAuthorPresence(torrentTitle, requestAuthor)) {
if (requireAuthor && !this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors)) {
// No high-confidence author match → reject to prevent wrong-author matches
return 0;
}
@@ -408,6 +452,10 @@ export class RankingAlgorithm {
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
let titleScore = 0;
// Keep original torrent title (lowercased only) for metadata marker detection
// Markers like [ ] ( ) : are removed by normalization but needed for suffix validation
const torrentTitleOriginal = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
// Try matching with full title first, then fall back to required title (without parentheses)
const titlesToTry = [requestTitle];
if (requiredTitle !== requestTitle) {
@@ -422,20 +470,37 @@ export class RankingAlgorithm {
const beforeTitle = torrentTitle.substring(0, titleIndex);
const afterTitle = torrentTitle.substring(titleIndex + titleToMatch.length);
// For metadata marker detection, try to find where the title starts in the ORIGINAL string
// Search for key words from the title to locate position in original
const titleWords = titleToMatch.split(/\s+/).filter(w => w.length > 2);
let afterTitleOriginal = '';
if (titleWords.length > 0) {
// Find the last significant title word in the original string
const lastTitleWord = titleWords[titleWords.length - 1];
const lastWordIdxOriginal = torrentTitleOriginal.lastIndexOf(lastTitleWord);
if (lastWordIdxOriginal !== -1) {
afterTitleOriginal = torrentTitleOriginal.substring(lastWordIdxOriginal + lastTitleWord.length);
}
}
// Extract significant words BEFORE the matched title
const beforeWords = extractWords(beforeTitle, stopWords);
// Title is complete if:
// 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ")
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
// Check ORIGINAL title for metadata markers ([ ] ( ) etc. not normalized away)
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
// Check if afterTitle starts with author name (handles space-separated format like "Title Author Year")
const afterStartsWithAuthor = requestAuthor.length > 2 &&
afterTitle.trim().startsWith(requestAuthor);
// Check if afterTitle starts with any author name (handles space-separated format like "Title Author Year")
const afterStartsWithAuthor = normalizedAuthors.some(author =>
author.length > 2 && afterTitle.trim().startsWith(author)
);
// Check metadata markers in both normalized and original suffixes
const hasMetadataSuffix = afterTitle === '' ||
metadataMarkers.some(marker => afterTitle.startsWith(marker)) ||
metadataMarkers.some(marker => afterTitleOriginal.startsWith(marker)) ||
afterStartsWithAuthor;
// Check prefix validity:
@@ -446,16 +511,32 @@ export class RankingAlgorithm {
// Check if title is immediately preceded by a metadata separator
// This handles "Author - Series - 01 - Title" patterns
// Check both normalized and original strings for separators
const precedingText = beforeTitle.trimEnd();
// Also check original string for separators that got normalized away (like colons)
let beforeTitleOriginal = '';
if (titleWords.length > 0) {
const firstTitleWord = titleWords[0];
const firstWordIdxOriginal = torrentTitleOriginal.indexOf(firstTitleWord);
if (firstWordIdxOriginal !== -1) {
beforeTitleOriginal = torrentTitleOriginal.substring(0, firstWordIdxOriginal).trimEnd();
}
}
const titlePrecededBySeparator =
precedingText.endsWith('-') ||
precedingText.endsWith(':') ||
precedingText.endsWith('—');
precedingText.endsWith('—') ||
beforeTitleOriginal.endsWith('-') ||
beforeTitleOriginal.endsWith(':') ||
beforeTitleOriginal.endsWith('—');
// Check if author name appears in the prefix
// Check if any author name appears in the prefix
// This handles "Author Name - Title" patterns
const authorInPrefix = requestAuthor.length > 2 &&
beforeTitle.includes(requestAuthor);
const authorInPrefix = normalizedAuthors.some(author =>
author.length > 2 && beforeTitle.includes(author)
);
const hasAcceptablePrefix =
hasNoWordsPrefix ||
@@ -481,24 +562,18 @@ export class RankingAlgorithm {
}
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
// Parse requested authors (split on separators, filter out roles)
const requestAuthors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Check how many authors appear in torrent title (exact substring match)
const authorMatches = requestAuthors.filter(author =>
const authorMatches = normalizedAuthors.filter(author =>
torrentTitle.includes(author)
);
let authorScore = 0;
if (authorMatches.length > 0) {
// Exact substring match → proportional credit
authorScore = (authorMatches.length / requestAuthors.length) * 15;
authorScore = (authorMatches.length / normalizedAuthors.length) * 15;
} else {
// No exact match → use fuzzy similarity for partial credit
authorScore = compareTwoStrings(requestAuthor, torrentTitle) * 15;
authorScore = compareTwoStrings(requestAuthorNormalized, torrentTitle) * 15;
}
return Math.min(60, titleScore + authorScore);
@@ -506,22 +581,16 @@ export class RankingAlgorithm {
/**
* Check if author is present in torrent title with high confidence
* Handles variations: middle initials, spacing, punctuation, name order
* Uses pre-parsed and normalized authors array
*
* @param torrentTitle - Normalized torrent title (lowercase)
* @param requestAuthor - Normalized author name (lowercase)
* @param torrentTitle - Normalized torrent title (already processed by normalizeForMatching)
* @param normalizedAuthors - Array of normalized author names (roles already filtered)
* @returns true if at least ONE author is present with high confidence
*/
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
// Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor
.split(/,|&| and | - /)
.map(a => a.trim())
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
private checkAuthorPresenceWithParsed(torrentTitle: string, normalizedAuthors: string[]): boolean {
// At least ONE author must match with high confidence
return authors.some(author => {
// Check 1: Exact substring match
return normalizedAuthors.some(author => {
// Check 1: Exact substring match (works well now that both are normalized)
if (torrentTitle.includes(author)) {
return true;
}
@@ -537,6 +606,7 @@ export class RankingAlgorithm {
// Check 3: Core name components (first + last name present within 30 chars)
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
// Handles: "Brandon R. Sanderson" vs "Brandon Sanderson"
// Now also handles: "VirginaEvans" → "virgina evans" (after normalization)
const words = author.split(/\s+/).filter(w => w.length > 1);
if (words.length >= 2) {
const firstName = words[0];
@@ -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
*/
@@ -687,6 +778,9 @@ export class RankingAlgorithm {
});
const ranked = filteredTorrents.map((torrent) => {
// Detect ebook format from title
const detectedFormat = this.detectEbookFormat(torrent);
// Calculate base scores (0-100)
// Reuse scoreMatch and scoreSeeders from audiobook ranking
const formatScore = this.scoreEbookFormat(torrent, ebook.preferredFormat);
@@ -765,6 +859,7 @@ export class RankingAlgorithm {
notes: [],
}, ebook.preferredFormat),
},
ebookFormat: detectedFormat !== 'unknown' ? detectedFormat : undefined,
};
});
@@ -824,19 +919,27 @@ export class RankingAlgorithm {
/**
* Detect ebook format from torrent title
* Handles formats in various positions: .epub, (epub), [epub], " epub"
*/
private detectEbookFormat(torrent: TorrentResult): string {
const title = torrent.title.toLowerCase();
// Check for common ebook format extensions/keywords
if (title.includes('.epub') || title.includes(' epub')) return 'epub';
if (title.includes('.pdf') || title.includes(' pdf')) return 'pdf';
if (title.includes('.mobi') || title.includes(' mobi')) return 'mobi';
if (title.includes('.azw3') || title.includes(' azw3')) return 'azw3';
if (title.includes('.azw') || title.includes(' azw')) return 'azw';
if (title.includes('.fb2') || title.includes(' fb2')) return 'fb2';
if (title.includes('.cbz') || title.includes(' cbz')) return 'cbz';
if (title.includes('.cbr') || title.includes(' cbr')) return 'cbr';
// Patterns: .format, (format), [format], " format", "_format"
const formats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr'];
for (const format of formats) {
if (
title.includes(`.${format}`) || // file.epub
title.includes(`(${format})`) || // (epub)
title.includes(`[${format}]`) || // [epub]
title.includes(` ${format}`) || // " epub" (space before)
title.includes(`_${format}`) || // _epub (underscore)
title.endsWith(format) // ends with format
) {
return format;
}
}
// Default to unknown
return 'unknown';
+153
View File
@@ -1034,6 +1034,159 @@ describe('ranking-algorithm', () => {
});
});
describe('Initial Variations (J.N. vs J N)', () => {
const algorithm = new RankingAlgorithm();
it('matches "J.N. Chaney" to torrent with "J N Chaney" in automatic mode', () => {
const torrent = {
...baseTorrent,
title: 'Infinite Crown by Terry Maggert, J N Chaney [ENG / M4B]',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Infinite Crown',
author: 'J.N. Chaney',
}, true); // requireAuthor: true (automatic mode)
// "J.N. Chaney" should normalize to "j n chaney"
// Torrent title should normalize to include "j n chaney"
// Author check should PASS
expect(breakdown.matchScore).toBeGreaterThan(0);
expect(breakdown.totalScore).toBeGreaterThanOrEqual(50);
});
it('matches author with periods to space-separated initials', () => {
const torrent = {
...baseTorrent,
title: 'Book Title by J K Rowling [M4B]',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Book Title',
author: 'J.K. Rowling',
}, true);
expect(breakdown.matchScore).toBeGreaterThan(0);
});
});
describe('CamelCase and Punctuation Separator Handling', () => {
const algorithm = new RankingAlgorithm();
it('matches CamelCase torrent title "VirginaEvans TheCorrespondent" to "The Correspondent" by "Virginia Evans"', () => {
const torrent = {
...baseTorrent,
title: 'VirginaEvans TheCorrespondent',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Correspondent',
author: 'Virginia Evans',
}, false); // requireAuthor: false - source has typo "Virgina" vs "Virginia"
// Should match after CamelCase normalization
// "VirginaEvans TheCorrespondent" → "virgina evans the correspondent"
// "The Correspondent" → "the correspondent" → required words: ["correspondent"]
// Coverage: "correspondent" found → passes
// Note: Author has typo in source data ("Virgina" vs "Virginia"), so fuzzy matching gives partial credit
expect(breakdown.matchScore).toBeGreaterThan(35);
});
it('matches period-separated title "Twelve.Months-Jim.Butcher" to "Twelve Months" by "Jim Butcher"', () => {
const torrent = {
...baseTorrent,
title: 'Twelve.Months-Jim.Butcher',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Twelve Months',
author: 'Jim Butcher',
});
// Should match after punctuation normalization
// "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
// Full title match + author match
expect(breakdown.matchScore).toBeGreaterThan(55);
});
it('matches mixed CamelCase and punctuation "AuthorName-BookTitle.2024"', () => {
const torrent = {
...baseTorrent,
title: 'JohnSmith-GreatBook.2024',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Great Book',
author: 'John Smith',
});
// "JohnSmith-GreatBook.2024" → "john smith great book 2024"
// Gets good fuzzy match score (title words present, author present)
expect(breakdown.matchScore).toBeGreaterThan(35);
});
it('matches CamelCase author with no separator "AuthorNameBookTitle"', () => {
const torrent = {
...baseTorrent,
title: 'BrandonSandersonMistborn',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Mistborn',
author: 'Brandon Sanderson',
});
// "BrandonSandersonMistborn" → "brandon sanderson mistborn"
expect(breakdown.matchScore).toBeGreaterThan(50);
});
it('handles underscore separators "Author_Name_Book_Title"', () => {
const torrent = {
...baseTorrent,
title: 'Jane_Doe_Amazing_Story',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Amazing Story',
author: 'Jane Doe',
});
// "Jane_Doe_Amazing_Story" → "jane doe amazing story"
expect(breakdown.matchScore).toBeGreaterThan(50);
});
it('preserves apostrophes in names like "O\'Brien"', () => {
const torrent = {
...baseTorrent,
title: "Tim O'Brien - The Things They Carried",
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Things They Carried',
author: "Tim O'Brien",
});
// Apostrophe should be preserved
expect(breakdown.matchScore).toBeGreaterThan(50);
});
it('handles real-world NZB title format with periods', () => {
const torrent = {
...baseTorrent,
title: 'William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Berlin Diary',
author: 'William L. Shirer',
});
// "William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs" → "william l shirer berlin diary audiobook 96kbs"
// Gets partial score from fuzzy matching (title words + author words present)
expect(breakdown.matchScore).toBeGreaterThan(30);
});
});
describe('Legacy API Compatibility', () => {
it('supports legacy rankTorrents signature with separate parameters', () => {
const torrent = {