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)
|
||||
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
|
||||
|
||||
|
||||
@@ -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)**
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1034,6 +1034,159 @@ describe('ranking-algorithm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Initial Variations (J.N. vs J N)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches "J.N. Chaney" to torrent with "J N Chaney" in automatic mode', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Infinite Crown by Terry Maggert, J N Chaney [ENG / M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Infinite Crown',
|
||||
author: 'J.N. Chaney',
|
||||
}, true); // requireAuthor: true (automatic mode)
|
||||
|
||||
// "J.N. Chaney" should normalize to "j n chaney"
|
||||
// Torrent title should normalize to include "j n chaney"
|
||||
// Author check should PASS
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
expect(breakdown.totalScore).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('matches author with periods to space-separated initials', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Book Title by J K Rowling [M4B]',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Book Title',
|
||||
author: 'J.K. Rowling',
|
||||
}, true);
|
||||
|
||||
expect(breakdown.matchScore).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CamelCase and Punctuation Separator Handling', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
|
||||
it('matches CamelCase torrent title "VirginaEvans TheCorrespondent" to "The Correspondent" by "Virginia Evans"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'VirginaEvans TheCorrespondent',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Correspondent',
|
||||
author: 'Virginia Evans',
|
||||
}, false); // requireAuthor: false - source has typo "Virgina" vs "Virginia"
|
||||
|
||||
// Should match after CamelCase normalization
|
||||
// "VirginaEvans TheCorrespondent" → "virgina evans the correspondent"
|
||||
// "The Correspondent" → "the correspondent" → required words: ["correspondent"]
|
||||
// Coverage: "correspondent" found → passes
|
||||
// Note: Author has typo in source data ("Virgina" vs "Virginia"), so fuzzy matching gives partial credit
|
||||
expect(breakdown.matchScore).toBeGreaterThan(35);
|
||||
});
|
||||
|
||||
it('matches period-separated title "Twelve.Months-Jim.Butcher" to "Twelve Months" by "Jim Butcher"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Twelve.Months-Jim.Butcher',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Twelve Months',
|
||||
author: 'Jim Butcher',
|
||||
});
|
||||
|
||||
// Should match after punctuation normalization
|
||||
// "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
|
||||
// Full title match + author match
|
||||
expect(breakdown.matchScore).toBeGreaterThan(55);
|
||||
});
|
||||
|
||||
it('matches mixed CamelCase and punctuation "AuthorName-BookTitle.2024"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'JohnSmith-GreatBook.2024',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Great Book',
|
||||
author: 'John Smith',
|
||||
});
|
||||
|
||||
// "JohnSmith-GreatBook.2024" → "john smith great book 2024"
|
||||
// Gets good fuzzy match score (title words present, author present)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(35);
|
||||
});
|
||||
|
||||
it('matches CamelCase author with no separator "AuthorNameBookTitle"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'BrandonSandersonMistborn',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Mistborn',
|
||||
author: 'Brandon Sanderson',
|
||||
});
|
||||
|
||||
// "BrandonSandersonMistborn" → "brandon sanderson mistborn"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('handles underscore separators "Author_Name_Book_Title"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'Jane_Doe_Amazing_Story',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Amazing Story',
|
||||
author: 'Jane Doe',
|
||||
});
|
||||
|
||||
// "Jane_Doe_Amazing_Story" → "jane doe amazing story"
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('preserves apostrophes in names like "O\'Brien"', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: "Tim O'Brien - The Things They Carried",
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Things They Carried',
|
||||
author: "Tim O'Brien",
|
||||
});
|
||||
|
||||
// Apostrophe should be preserved
|
||||
expect(breakdown.matchScore).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('handles real-world NZB title format with periods', () => {
|
||||
const torrent = {
|
||||
...baseTorrent,
|
||||
title: 'William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs',
|
||||
};
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Berlin Diary',
|
||||
author: 'William L. Shirer',
|
||||
});
|
||||
|
||||
// "William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs" → "william l shirer berlin diary audiobook 96kbs"
|
||||
// Gets partial score from fuzzy matching (title words + author words present)
|
||||
expect(breakdown.matchScore).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legacy API Compatibility', () => {
|
||||
it('supports legacy rankTorrents signature with separate parameters', () => {
|
||||
const torrent = {
|
||||
|
||||
Reference in New Issue
Block a user