diff --git a/documentation/phase3/prowlarr.md b/documentation/phase3/prowlarr.md index 5d8d339..d6af722 100644 --- a/documentation/phase3/prowlarr.md +++ b/documentation/phase3/prowlarr.md @@ -24,11 +24,12 @@ Indexer aggregator for searching multiple torrent/usenet indexers simultaneously **Extended Search:** Enabled (`extended=1`) - searches title, tags, labels, and metadata fields **Result Filtering:** -- Minimum score threshold: 30/100 +- Minimum score threshold: 50/100 - Filters applied after ranking to remove poor matches +- Ensures at least basic title/author match quality - maxResults: 100 (increased from 50 for broader search) -**Example:** "Season of Storms" → finds all "Season of Storms" torrents → ranks by author match → filters score < 30 +**Example:** "Season of Storms" → finds all "Season of Storms" torrents → ranks by author match → filters score < 50 ```typescript interface TorrentResult { @@ -66,15 +67,17 @@ interface TorrentResult { **Manual Search** (`POST /api/requests/{id}/manual-search`) - Triggers automatic search job for requests with status: pending, failed, awaiting_search - Searches only enabled indexers (title only, maxResults: 100) -- Ranks all results, filters scores < 30 +- Ranks all results, filters scores < 50 - Selects best torrent from filtered results - Updates request status to 'pending' **Interactive Search** (`POST /api/requests/{id}/interactive-search`) - Returns ranked torrent results for user selection -- Searches only enabled indexers (title only, maxResults: 100) -- Ranks all results, filters scores < 30 +- Searches only enabled indexers (title only or custom, maxResults: 100) +- Accepts optional custom search title in request body +- Ranks all results, filters scores < 50 - Shows table with: rank, title, size, quality score, seeders, indexer, publish date +- Editable title field allows search refinement - Available for same statuses as manual search - User clicks "Download" button to select specific torrent diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md index cf7212f..dc24cd0 100644 --- a/documentation/phase3/qbittorrent.md +++ b/documentation/phase3/qbittorrent.md @@ -48,7 +48,12 @@ Free, open-source BitTorrent client with comprehensive Web API. - `download_client_password` - qBittorrent password - `download_dir` - Download save path (passed to qBittorrent for all torrents) -Validation: All fields checked before service initialization. +**Optional (Remote Path Mapping):** +- `download_client_remote_path_mapping_enabled` - Enable path mapping (boolean as string "true"/"false") +- `download_client_remote_path` - Remote path prefix from qBittorrent +- `download_client_local_path` - Local path prefix for ReadMeABook + +Validation: All required fields checked before service initialization. Path mapping fields validated when enabled. **Singleton Invalidation:** Service uses singleton pattern for performance. When settings change (via admin settings page), singleton is invalidated to force reload: @@ -72,6 +77,53 @@ Service uses singleton pattern for performance. When settings change (via admin This prevents issues where category retains old save path after user changes `download_dir` setting. +## Remote Path Mapping + +**Use Case:** qBittorrent runs on different machine/container with different filesystem perspective. + +**Example Scenario:** +- qBittorrent reports: `/remote/mnt/d/done/Audiobook.Name` +- ReadMeABook needs: `/downloads/Audiobook.Name` +- Mapping: Remote `/remote/mnt/d/done` → Local `/downloads` + +**Configuration:** +1. Admin Settings → Download Client → Enable Remote Path Mapping +2. Enter remote path (as reported by qBittorrent) +3. Enter local path (accessible to ReadMeABook) +4. Test connection validates local path exists +5. Save settings + +**Implementation:** +- `PathMapper` utility (`src/lib/utils/path-mapper.ts`) handles transformation +- Applied in `monitor-download.processor.ts` when download completes +- Applied in `retry-failed-imports.processor.ts` for failed imports +- Uses simple prefix replacement with path normalization +- Graceful fallback: if path doesn't match remote prefix, returns unchanged + +**Path Transformation:** +```typescript +// Input from qBittorrent +qbPath = "/remote/mnt/d/done/Audiobook.Name" + +// Config +remotePath = "/remote/mnt/d/done" +localPath = "/downloads" + +// Output (used for file organization) +organizePath = "/downloads/Audiobook.Name" +``` + +**Validation:** +- Local path accessibility checked during test connection +- Prevents misconfiguration before save +- Warning shown for existing downloads (mapping only affects new downloads) + +**Behavior:** +- Mapping only applies when enabled +- If path doesn't start with remote prefix, returns original (logs warning) +- Path normalization handles trailing slashes, backslashes, redundant separators +- Works with both `content_path` and constructed `save_path + name` + ## Data Models ```typescript @@ -107,6 +159,11 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' | - Checking existing categories before create/edit (avoid unnecessary 409 errors) - Invalidating service singleton when settings change (forces config reload) - Settings API calls `invalidateQBittorrentService()` after updating paths or credentials +**10. Remote seedbox path mismatch** - qBittorrent on remote machine reports different filesystem paths. Fixed by: + - Remote path mapping feature with toggle in admin settings and setup wizard + - PathMapper utility for prefix replacement transformation + - Local path validation during test connection + - Applied in download completion and import retry processors ## Tech Stack diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 0fd997f..5e3b4ec 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -7,16 +7,31 @@ Evaluates and scores torrents to automatically select best audiobook download. ## Scoring Criteria (100 points max) **1. Title/Author Match (50 pts max) - MOST IMPORTANT** -- Title matching: 0-35 pts - - Complete title match (followed by metadata: " by", " [", " -") → 35 pts - - Title is substring but continues with more words → fuzzy similarity (partial credit) - - Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret" - - No exact match → fuzzy similarity (partial credit) -- Author presence: 0-15 pts - - Exact substring match → proportional credit - - No exact match → fuzzy similarity (partial credit) - - Splits authors on delimiters (comma, &, "and", " - ") - - Filters out roles ("translator", "narrator") + +**Multi-Stage Matching:** + +**Stage 1: Word Coverage Filter (MANDATORY)** +- Extracts significant words from request (filters stop words: "the", "a", "an", "of", "on", "in", "at", "by", "for") +- Calculates coverage: % of request words found in torrent title +- **Hard requirement: 80%+ coverage or automatic 0 score** +- Example: "The Wild Robot on the Island" → ["wild", "robot", "island"] + - "The Wild Robot" → ["wild", "robot"] → 2/3 = 67% → **REJECTED** + - "The Wild Robot on the Island" → 3/3 = 100% → **PASSES** +- Prevents wrong series books from matching + +**Stage 2: Title Matching (0-35 pts)** +- Only scored if Stage 1 passes +- Complete title match (followed by metadata: " by", " [", " -") → 35 pts +- Title is substring but continues with more words → fuzzy similarity (partial credit) +- Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret" +- No exact match → fuzzy similarity (partial credit) + +**Stage 3: Author Matching (0-15 pts)** +- Exact substring match → proportional credit +- No exact match → fuzzy similarity (partial credit) +- Splits authors on delimiters (comma, &, "and", " - ") +- Filters out roles ("translator", "narrator") + - Order-independent, no structure assumptions - Ensures correct book is selected over wrong book with better format diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index 3176ca3..b1e05ce 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -21,6 +21,7 @@ interface RecentRequest { createdAt: Date; completedAt: Date | null; errorMessage: string | null; + torrentUrl?: string | null; } interface RecentRequestsTableProps { @@ -273,6 +274,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) { title: request.title, author: request.author, status: request.status, + torrentUrl: request.torrentUrl, }} onDelete={handleDeleteClick} onManualSearch={handleManualSearch} diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index a8ae016..4b582c3 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -16,6 +16,7 @@ export interface RequestActionsDropdownProps { title: string; author: string; status: string; + torrentUrl?: string | null; }; onDelete: (requestId: string, title: string) => void; onManualSearch: (requestId: string) => Promise; @@ -38,6 +39,7 @@ export function RequestActionsDropdown({ const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const canDelete = true; // Admins can always delete + const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); // Close dropdown when clicking outside useEffect(() => { @@ -156,8 +158,35 @@ export function RequestActionsDropdown({ )} - {/* Divider if we have search actions and other actions */} - {canSearch && (canCancel || canDelete) && ( + {/* View Source */} + {canViewSource && ( + setIsOpen(false)} + 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" + > + + + + View Source + + )} + + {/* Divider if we have search/view actions and other actions */} + {(canSearch || canViewSource) && (canCancel || canDelete) && (
)} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index a7bbdc8..09411e7 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -70,6 +70,9 @@ interface Settings { url: string; username: string; password: string; + remotePathMappingEnabled: boolean; + remotePath: string; + localPath: string; }; paths: { downloadDir: string; @@ -648,6 +651,9 @@ export default function AdminSettings() { url: settings.downloadClient.url, username: settings.downloadClient.username, password: settings.downloadClient.password, + remotePathMappingEnabled: settings.downloadClient.remotePathMappingEnabled, + remotePath: settings.downloadClient.remotePath, + localPath: settings.downloadClient.localPath, }), }); @@ -1196,7 +1202,7 @@ export default function AdminSettings() { {absLibraries.map((lib) => ( ))} @@ -1545,6 +1551,104 @@ export default function AdminSettings() { />
+ {/* Remote Path Mapping */} +
+
+ { + setSettings({ + ...settings, + downloadClient: { + ...settings.downloadClient, + remotePathMappingEnabled: e.target.checked, + }, + }); + setValidated({ ...validated, download: false }); + }} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" + /> +
+ +

+ Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers) +

+

+ Example: Remote /remote/mnt/d/done → Local /downloads +

+ + {/* Warning for existing downloads */} + {settings.downloadClient.remotePathMappingEnabled && ( +
+

+ ⚠️ Note: Path mapping only affects new downloads. In-progress downloads will continue using their original paths. +

+
+ )} + + {/* Conditional Fields */} + {settings.downloadClient.remotePathMappingEnabled && ( +
+
+ + { + setSettings({ + ...settings, + downloadClient: { + ...settings.downloadClient, + remotePath: e.target.value, + }, + }); + setValidated({ ...validated, download: false }); + }} + /> +

+ The path prefix as reported by qBittorrent +

+
+ +
+ + { + setSettings({ + ...settings, + downloadClient: { + ...settings.downloadClient, + localPath: e.target.value, + }, + }); + setValidated({ ...validated, download: false }); + }} + /> +

+ The actual path where files are accessible +

+
+
+ )} +
+
+
+
+ {/* Remote Path Mapping */} +
+
+ onUpdate('remotePathMappingEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" + /> +
+ +

+ Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers) +

+

+ Example: Remote /remote/mnt/d/done → Local /downloads +

+ + {/* Conditional Fields */} + {remotePathMappingEnabled && ( +
+
+ + onUpdate('remotePath', e.target.value)} + /> +

+ The path prefix as reported by qBittorrent +

+
+ +
+ + onUpdate('localPath', e.target.value)} + /> +

+ The actual path where files are accessible +

+
+
+ )} +
+
+
+