mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add remote path mapping for qBittorrent integration
Implements remote-to-local path mapping for qBittorrent downloads, allowing the app to handle differing filesystem paths between qBittorrent and the local environment (e.g., remote seedboxes, Docker). Adds UI controls in admin settings and setup wizard, validates mapping configuration, and applies path transformation in download and import processors. Updates documentation, API routes, and data models to support the new feature. Also improves library scan logic to remove stale records and reset orphaned audiobooks and requests. Increases minimum torrent score threshold from 30 to 50 in search and ranking logic, and exposes torrent source URLs in the admin UI.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search actions and other actions */}
|
||||
{canSearch && (canCancel || canDelete) && (
|
||||
{/* View Source */}
|
||||
{canViewSource && (
|
||||
<a
|
||||
href={request.torrentUrl!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
View Source
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view actions and other actions */}
|
||||
{(canSearch || canViewSource) && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<option value="">Select a library...</option>
|
||||
{absLibraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name} ({lib.itemCount} items)
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1545,6 +1551,104 @@ export default function AdminSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remote-path-mapping"
|
||||
checked={settings.downloadClient.remotePathMappingEnabled}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="remote-path-mapping"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Remote Path Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||
</p>
|
||||
|
||||
{/* Warning for existing downloads */}
|
||||
{settings.downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ <strong>Note:</strong> Path mapping only affects new downloads. In-progress downloads will continue using their original paths.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{settings.downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Remote Path (from qBittorrent)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/remote/mnt/d/done"
|
||||
value={settings.downloadClient.remotePath}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
downloadClient: {
|
||||
...settings.downloadClient,
|
||||
remotePath: e.target.value,
|
||||
},
|
||||
});
|
||||
setValidated({ ...validated, download: false });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The path prefix as reported by qBittorrent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Local Path (for ReadMeABook)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/downloads"
|
||||
value={settings.downloadClient.localPath}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
downloadClient: {
|
||||
...settings.downloadClient,
|
||||
localPath: e.target.value,
|
||||
},
|
||||
});
|
||||
setValidated({ ...validated, download: false });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The actual path where files are accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testDownloadClientConnection}
|
||||
|
||||
@@ -48,6 +48,8 @@ export async function GET(request: NextRequest) {
|
||||
downloadStatus: true,
|
||||
torrentName: true,
|
||||
torrentHash: true,
|
||||
startedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -75,7 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.updatedAt,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
}));
|
||||
return NextResponse.json({ downloads: formatted });
|
||||
}
|
||||
@@ -112,7 +114,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.updatedAt,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -30,6 +30,15 @@ export async function GET(request: NextRequest) {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
downloadHistory: {
|
||||
where: {
|
||||
selected: true,
|
||||
},
|
||||
select: {
|
||||
torrentUrl: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
@@ -47,6 +56,7 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: request.createdAt,
|
||||
completedAt: request.completedAt,
|
||||
errorMessage: request.errorMessage,
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ requests: formatted });
|
||||
|
||||
@@ -6,12 +6,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { PathMapper } from '@/lib/utils/path-mapper';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
const {
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
} = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -28,6 +37,33 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate path mapping if enabled
|
||||
if (remotePathMappingEnabled) {
|
||||
if (!remotePath || !localPath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Remote path and local path are required when path mapping is enabled' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
PathMapper.validate({
|
||||
enabled: true,
|
||||
remotePath,
|
||||
localPath,
|
||||
});
|
||||
} catch (validationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: validationError instanceof Error
|
||||
? validationError.message
|
||||
: 'Invalid path mapping configuration',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_type' },
|
||||
@@ -56,6 +92,28 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Save remote path mapping configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path_mapping_enabled' },
|
||||
update: { value: remotePathMappingEnabled ? 'true' : 'false' },
|
||||
create: {
|
||||
key: 'download_client_remote_path_mapping_enabled',
|
||||
value: remotePathMappingEnabled ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path' },
|
||||
update: { value: remotePath || '' },
|
||||
create: { key: 'download_client_remote_path', value: remotePath || '' },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_local_path' },
|
||||
update: { value: localPath || '' },
|
||||
create: { key: 'download_client_local_path', value: localPath || '' },
|
||||
});
|
||||
|
||||
console.log('[Admin] Download client settings updated');
|
||||
|
||||
// Invalidate qBittorrent service singleton to force reload of credentials and URL
|
||||
|
||||
@@ -72,6 +72,9 @@ export async function GET(request: NextRequest) {
|
||||
username: configMap.get('download_client_username') || '',
|
||||
password: maskValue('password', configMap.get('download_client_password')),
|
||||
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
|
||||
remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true',
|
||||
remotePath: configMap.get('download_client_remote_path') || '',
|
||||
localPath: configMap.get('download_client_local_path') || '',
|
||||
},
|
||||
paths: {
|
||||
downloadDir: configMap.get('download_dir') || '/downloads',
|
||||
|
||||
@@ -12,7 +12,15 @@ export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
const {
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
} = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -52,6 +60,33 @@ export async function POST(request: NextRequest) {
|
||||
actualPassword
|
||||
);
|
||||
|
||||
// If path mapping enabled, validate local path exists
|
||||
if (remotePathMappingEnabled) {
|
||||
if (!remotePath || !localPath) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Remote path and local path are required when path mapping is enabled',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if local path is accessible
|
||||
const fs = await import('fs/promises');
|
||||
try {
|
||||
await fs.access(localPath, fs.constants.R_OK);
|
||||
} catch (accessError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Local path "${localPath}" is not accessible. Please verify the path exists and has correct permissions.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
version,
|
||||
|
||||
@@ -79,10 +79,10 @@ export async function POST(request: NextRequest) {
|
||||
// Rank torrents using the ranking algorithm
|
||||
const rankedResults = rankTorrents(results, { title, author });
|
||||
|
||||
// Filter out results below minimum score threshold (30/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 30);
|
||||
// Filter out results below minimum score threshold (50/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 50);
|
||||
|
||||
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
|
||||
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
|
||||
|
||||
// Log top 3 results with detailed score breakdown for debugging
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
|
||||
@@ -114,10 +114,10 @@ export async function POST(
|
||||
author: requestRecord.audiobook.author,
|
||||
});
|
||||
|
||||
// Filter out results below minimum score threshold (30/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 30);
|
||||
// Filter out results below minimum score threshold (50/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 50);
|
||||
|
||||
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
|
||||
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
|
||||
|
||||
// Log top 3 results with detailed score breakdown for debugging
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
|
||||
@@ -356,6 +356,28 @@ export async function POST(request: NextRequest) {
|
||||
create: { key: 'download_client_password', value: downloadClient.password },
|
||||
});
|
||||
|
||||
// Remote path mapping configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path_mapping_enabled' },
|
||||
update: { value: downloadClient.remotePathMappingEnabled ? 'true' : 'false' },
|
||||
create: {
|
||||
key: 'download_client_remote_path_mapping_enabled',
|
||||
value: downloadClient.remotePathMappingEnabled ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path' },
|
||||
update: { value: downloadClient.remotePath || '' },
|
||||
create: { key: 'download_client_remote_path', value: downloadClient.remotePath || '' },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_local_path' },
|
||||
update: { value: downloadClient.localPath || '' },
|
||||
create: { key: 'download_client_local_path', value: downloadClient.localPath || '' },
|
||||
});
|
||||
|
||||
// Path configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
|
||||
@@ -77,6 +77,9 @@ interface SetupState {
|
||||
downloadClientUrl: string;
|
||||
downloadClientUsername: string;
|
||||
downloadClientPassword: string;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
@@ -142,6 +145,9 @@ export default function SetupWizard() {
|
||||
downloadClientUrl: '',
|
||||
downloadClientUsername: 'admin',
|
||||
downloadClientPassword: '',
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media/audiobooks',
|
||||
metadataTaggingEnabled: true,
|
||||
@@ -211,6 +217,9 @@ export default function SetupWizard() {
|
||||
url: state.downloadClientUrl,
|
||||
username: state.downloadClientUsername,
|
||||
password: state.downloadClientPassword,
|
||||
remotePathMappingEnabled: state.remotePathMappingEnabled,
|
||||
remotePath: state.remotePath,
|
||||
localPath: state.localPath,
|
||||
},
|
||||
paths: {
|
||||
download_dir: state.downloadDir,
|
||||
@@ -494,6 +503,9 @@ export default function SetupWizard() {
|
||||
downloadClientUrl={state.downloadClientUrl}
|
||||
downloadClientUsername={state.downloadClientUsername}
|
||||
downloadClientPassword={state.downloadClientPassword}
|
||||
remotePathMappingEnabled={state.remotePathMappingEnabled}
|
||||
remotePath={state.remotePath}
|
||||
localPath={state.localPath}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
|
||||
@@ -217,7 +217,7 @@ export function AudiobookshelfStep({
|
||||
<option value="">Select a library...</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name} ({lib.itemCount} items)
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -14,7 +14,10 @@ interface DownloadClientStepProps {
|
||||
downloadClientUrl: string;
|
||||
downloadClientUsername: string;
|
||||
downloadClientPassword: string;
|
||||
onUpdate: (field: string, value: string) => void;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -24,6 +27,9 @@ export function DownloadClientStep({
|
||||
downloadClientUrl,
|
||||
downloadClientUsername,
|
||||
downloadClientPassword,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -48,6 +54,9 @@ export function DownloadClientStep({
|
||||
url: downloadClientUrl,
|
||||
username: downloadClientUsername,
|
||||
password: downloadClientPassword,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -176,6 +185,68 @@ export function DownloadClientStep({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="mt-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remote-path-mapping-setup"
|
||||
checked={remotePathMappingEnabled}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="remote-path-mapping-setup"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Remote Path Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||
</p>
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{remotePathMappingEnabled && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Remote Path (from qBittorrent)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/remote/mnt/d/done"
|
||||
value={remotePath}
|
||||
onChange={(e) => onUpdate('remotePath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The path prefix as reported by qBittorrent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Local Path (for ReadMeABook)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/downloads"
|
||||
value={localPath}
|
||||
onChange={(e) => onUpdate('localPath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The actual path where files are accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
|
||||
@@ -9,13 +9,32 @@ import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface ReviewStepProps {
|
||||
config: {
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
|
||||
// Plex config
|
||||
plexUrl: string;
|
||||
plexLibraryId: string;
|
||||
|
||||
// Audiobookshelf config
|
||||
absUrl: string;
|
||||
absLibraryId: string;
|
||||
|
||||
// Auth config (ABS mode)
|
||||
authMethod: 'oidc' | 'manual' | 'both';
|
||||
oidcProviderName: string;
|
||||
adminUsername: string;
|
||||
|
||||
// Common config
|
||||
prowlarrUrl: string;
|
||||
downloadClient: 'qbittorrent' | 'transmission';
|
||||
downloadClientUrl: string;
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
|
||||
// BookDate
|
||||
bookdateConfigured: boolean;
|
||||
bookdateProvider: string;
|
||||
bookdateModel: string;
|
||||
};
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -58,26 +77,82 @@ export function ReviewStep({ config, loading, error, onComplete, onBack }: Revie
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Plex Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Plex Media Server
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexUrl}
|
||||
</dd>
|
||||
{/* Backend Configuration - Conditional based on mode */}
|
||||
{config.backendMode === 'plex' ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Plex Media Server
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexUrl}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexLibraryId}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Audiobookshelf Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Audiobookshelf
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.absUrl}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.absLibraryId}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexLibraryId}
|
||||
</dd>
|
||||
|
||||
{/* Authentication Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Authentication
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Auth Method:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||
{config.authMethod === 'both' ? 'OIDC + Manual Registration' : config.authMethod === 'oidc' ? 'OIDC' : 'Manual Registration'}
|
||||
</dd>
|
||||
</div>
|
||||
{(config.authMethod === 'oidc' || config.authMethod === 'both') && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">OIDC Provider:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.oidcProviderName}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{(config.authMethod === 'manual' || config.authMethod === 'both') && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Admin Username:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.adminUsername}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prowlarr Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
@@ -135,6 +210,29 @@ export function ReviewStep({ config, loading, error, onComplete, onBack }: Revie
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* BookDate Configuration (Optional) */}
|
||||
{config.bookdateConfigured && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
BookDate AI Recommendations
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Provider:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||
{config.bookdateProvider}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Model:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.bookdateModel}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
|
||||
@@ -61,6 +61,8 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
torrentName: torrent.title,
|
||||
torrentHash: torrent.infoHash || torrentHash,
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: torrent.guid, // Source URL for the torrent page
|
||||
magnetLink: torrent.downloadUrl, // Download URL (magnet or .torrent)
|
||||
seeders: torrent.seeders,
|
||||
leechers: torrent.leechers || 0,
|
||||
downloadStatus: 'downloading',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queu
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { createJobLogger, JobLogger } from '../utils/job-logger';
|
||||
import { PathMapper } from '../utils/path-mapper';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
|
||||
/**
|
||||
* Helper function to retry getTorrent with exponential backoff
|
||||
@@ -94,16 +96,32 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
// Determine actual content path for file organization
|
||||
// Priority 1: Use content_path if provided by qBittorrent (most reliable)
|
||||
// Priority 2: Construct path using path.join() for proper normalization
|
||||
const organizePath = torrent.content_path
|
||||
const qbPath = torrent.content_path
|
||||
? torrent.content_path
|
||||
: path.join(torrent.save_path, torrent.name);
|
||||
|
||||
// Load path mapping configuration
|
||||
const configService = getConfigService();
|
||||
const pathMappingConfig = await configService.getMany([
|
||||
'download_client_remote_path_mapping_enabled',
|
||||
'download_client_remote_path',
|
||||
'download_client_local_path',
|
||||
]);
|
||||
|
||||
// Apply remote-to-local path transformation if enabled
|
||||
const organizePath = PathMapper.transform(qbPath, {
|
||||
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
|
||||
remotePath: pathMappingConfig.download_client_remote_path || '',
|
||||
localPath: pathMappingConfig.download_client_local_path || '',
|
||||
});
|
||||
|
||||
await logger?.info(`Download completed`, {
|
||||
filesCount: files.length,
|
||||
torrentName: torrent.name,
|
||||
savePath: torrent.save_path,
|
||||
contentPath: torrent.content_path || '(not provided)',
|
||||
organizePath,
|
||||
qbittorrentPath: qbPath,
|
||||
organizePath: organizePath !== qbPath ? `${organizePath} (mapped)` : organizePath,
|
||||
});
|
||||
|
||||
// Update download history to completed
|
||||
|
||||
@@ -9,6 +9,7 @@ import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { PathMapper } from '../utils/path-mapper';
|
||||
|
||||
export interface RetryFailedImportsPayload {
|
||||
jobId?: string;
|
||||
@@ -22,6 +23,20 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
await logger?.info('Starting retry job for requests awaiting import...');
|
||||
|
||||
try {
|
||||
// Load path mapping configuration once
|
||||
const configService = getConfigService();
|
||||
const pathMappingConfig = await configService.getMany([
|
||||
'download_client_remote_path_mapping_enabled',
|
||||
'download_client_remote_path',
|
||||
'download_client_local_path',
|
||||
]);
|
||||
|
||||
const mappingConfig = {
|
||||
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
|
||||
remotePath: pathMappingConfig.download_client_remote_path || '',
|
||||
localPath: pathMappingConfig.download_client_local_path || '',
|
||||
};
|
||||
|
||||
// Find all active requests in awaiting_import status
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
@@ -73,8 +88,12 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
|
||||
downloadPath = `${torrent.save_path}/${torrent.name}`;
|
||||
await logger?.info(`Got download path from qBittorrent for request ${request.id}: ${downloadPath}`);
|
||||
const qbPath = `${torrent.save_path}/${torrent.name}`;
|
||||
downloadPath = PathMapper.transform(qbPath, mappingConfig);
|
||||
await logger?.info(
|
||||
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
|
||||
(downloadPath !== qbPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} catch (qbtError) {
|
||||
// Torrent not found in qBittorrent - try to construct path from config
|
||||
await logger?.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
@@ -85,7 +104,6 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
@@ -94,8 +112,12 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
await logger?.info(`Using fallback download path for request ${request.id}: ${downloadPath}`);
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
await logger?.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No download client ID - use fallback path
|
||||
@@ -105,7 +127,6 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
@@ -114,8 +135,12 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
await logger?.info(`Using configured download path for request ${request.id}: ${downloadPath}`);
|
||||
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
|
||||
await logger?.info(
|
||||
`Using configured download path for request ${request.id}: ${configuredPath}` +
|
||||
(downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
|
||||
@@ -137,7 +137,186 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
|
||||
await logger?.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
|
||||
|
||||
// 5. Match downloaded requests against library
|
||||
// 5. Remove stale records from plex_library (items no longer in the actual library)
|
||||
// This ensures the database is a fresh snapshot of the library state
|
||||
await logger?.info(`Checking for stale library records...`);
|
||||
|
||||
const scannedPlexGuids = libraryItems
|
||||
.filter(item => item.externalId)
|
||||
.map(item => item.externalId);
|
||||
|
||||
let staleRemovedCount = 0;
|
||||
let audiobooksReset = 0;
|
||||
let requestsReset = 0;
|
||||
|
||||
// Safety check: Only remove stale records if we actually scanned items
|
||||
// This prevents accidentally deleting everything if the library scan fails or returns empty
|
||||
if (scannedPlexGuids.length > 0) {
|
||||
// Find all plex_library entries for this library that were NOT seen in this scan
|
||||
const staleLibraryItems = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
plexLibraryId: targetLibraryId,
|
||||
plexGuid: {
|
||||
notIn: scannedPlexGuids,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (staleLibraryItems.length > 0) {
|
||||
await logger?.info(`Found ${staleLibraryItems.length} stale library records to remove`);
|
||||
|
||||
// For each stale library item, clean up references
|
||||
for (const staleItem of staleLibraryItems) {
|
||||
try {
|
||||
// Find audiobooks that reference this stale library item
|
||||
const linkedAudiobooks = await prisma.audiobook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ plexGuid: staleItem.plexGuid },
|
||||
{ absItemId: staleItem.plexGuid },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
requests: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Reset audiobook records and their requests
|
||||
for (const audiobook of linkedAudiobooks) {
|
||||
// Clear library linkage
|
||||
const updateData: any = {
|
||||
status: 'requested',
|
||||
plexGuid: null,
|
||||
absItemId: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
audiobooksReset++;
|
||||
|
||||
// Reset any 'available' requests back to 'downloaded' or 'failed'
|
||||
for (const request of audiobook.requests) {
|
||||
if (request.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: 'downloaded', // Back to downloaded state (files may still be there)
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
requestsReset++;
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Reset audiobook "${staleItem.title}" (no longer in library)`);
|
||||
}
|
||||
|
||||
// Delete the stale library record
|
||||
await prisma.plexLibrary.delete({
|
||||
where: { id: staleItem.id },
|
||||
});
|
||||
|
||||
staleRemovedCount++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to remove stale library item "${staleItem.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Removed ${staleRemovedCount} stale records, reset ${audiobooksReset} audiobooks and ${requestsReset} requests`);
|
||||
} else {
|
||||
await logger?.info(`No stale library records found`);
|
||||
}
|
||||
} else {
|
||||
await logger?.warn(`Scan returned no items - skipping stale record cleanup to prevent data loss`);
|
||||
}
|
||||
|
||||
// 5b. Clean up orphaned audiobooks (audiobooks with plexGuid/absItemId that don't exist in plex_library)
|
||||
// This handles cases where the library record was already deleted but audiobook record wasn't updated
|
||||
await logger?.info(`Checking for orphaned audiobooks...`);
|
||||
|
||||
const allPlexGuidsInLibrary = await prisma.plexLibrary.findMany({
|
||||
select: { plexGuid: true },
|
||||
});
|
||||
const validPlexGuids = allPlexGuidsInLibrary.map(item => item.plexGuid);
|
||||
|
||||
let orphanedAudiobooksReset = 0;
|
||||
let orphanedRequestsReset = 0;
|
||||
|
||||
// Find audiobooks with plexGuid/absItemId that don't exist in plex_library
|
||||
const orphanedAudiobooks = await prisma.audiobook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
plexGuid: { not: null },
|
||||
},
|
||||
{
|
||||
absItemId: { not: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
requests: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const audiobook of orphanedAudiobooks) {
|
||||
const linkedId = audiobook.plexGuid || audiobook.absItemId;
|
||||
|
||||
// Skip if this audiobook's library ID is valid (exists in plex_library)
|
||||
if (linkedId && validPlexGuids.includes(linkedId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This audiobook is orphaned - its library link points to nothing
|
||||
try {
|
||||
await logger?.info(`Found orphaned audiobook: "${audiobook.title}" (linked to non-existent library item)`);
|
||||
|
||||
// Clear library linkage
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: {
|
||||
status: 'requested',
|
||||
plexGuid: null,
|
||||
absItemId: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
orphanedAudiobooksReset++;
|
||||
|
||||
// Reset any 'available' requests
|
||||
for (const request of audiobook.requests) {
|
||||
if (request.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: 'downloaded',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
orphanedRequestsReset++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to reset orphaned audiobook "${audiobook.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedAudiobooksReset > 0) {
|
||||
await logger?.info(`Reset ${orphanedAudiobooksReset} orphaned audiobooks and ${orphanedRequestsReset} requests`);
|
||||
} else {
|
||||
await logger?.info(`No orphaned audiobooks found`);
|
||||
}
|
||||
|
||||
// 6. Match downloaded requests against library
|
||||
await logger?.info(`Checking for downloaded requests to match...`);
|
||||
const downloadedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
@@ -205,6 +384,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
newCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
staleRemovedCount,
|
||||
audiobooksReset,
|
||||
requestsReset,
|
||||
orphanedAudiobooksReset,
|
||||
orphanedRequestsReset,
|
||||
matchedDownloads: matchedCount,
|
||||
});
|
||||
|
||||
@@ -217,6 +401,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
newCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
staleRemovedCount,
|
||||
audiobooksReset,
|
||||
requestsReset,
|
||||
orphanedAudiobooksReset,
|
||||
orphanedRequestsReset,
|
||||
newAudiobooks: results,
|
||||
matchedDownloads: matchedCount,
|
||||
};
|
||||
|
||||
@@ -98,14 +98,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
durationMinutes: undefined, // We don't have duration from Audible
|
||||
});
|
||||
|
||||
// Filter out results below minimum score threshold (30/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 30);
|
||||
// Filter out results below minimum score threshold (50/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 50);
|
||||
|
||||
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
|
||||
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
// No quality results found - queue for re-search instead of failing
|
||||
await logger?.warn(`No quality matches found for request ${requestId} (all below 30/100), marking as awaiting_search`);
|
||||
await logger?.warn(`No quality matches found for request ${requestId} (all below 50/100), marking as awaiting_search`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
|
||||
@@ -49,6 +49,9 @@ export async function deleteRequest(
|
||||
id: true,
|
||||
title: true,
|
||||
author: true,
|
||||
audibleAsin: true,
|
||||
plexGuid: true,
|
||||
absItemId: true,
|
||||
},
|
||||
},
|
||||
downloadHistory: {
|
||||
@@ -168,12 +171,39 @@ export async function deleteRequest(
|
||||
const configService = getConfigService();
|
||||
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
|
||||
|
||||
// Sanitize author and title for path
|
||||
// Sanitize author and title for path (same logic as file-organizer.ts)
|
||||
const sanitizedAuthor = sanitizePath(request.audiobook.author);
|
||||
const sanitizedTitle = sanitizePath(request.audiobook.title);
|
||||
|
||||
// Build path: [media_dir]/[author]/[title]/
|
||||
const titleFolderPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle);
|
||||
// Build folder name with optional year and ASIN (matches file-organizer.ts logic)
|
||||
let folderName = sanitizedTitle;
|
||||
|
||||
// Get ASIN and check for year in AudibleCache
|
||||
const asin = request.audiobook.audibleAsin;
|
||||
let year: number | undefined;
|
||||
|
||||
if (asin) {
|
||||
// Try to get year from AudibleCache if it exists
|
||||
const audibleCache = await prisma.audibleCache.findUnique({
|
||||
where: { asin },
|
||||
select: { releaseDate: true },
|
||||
});
|
||||
|
||||
if (audibleCache?.releaseDate) {
|
||||
year = new Date(audibleCache.releaseDate).getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
if (year) {
|
||||
folderName = `${folderName} (${year})`;
|
||||
}
|
||||
|
||||
if (asin) {
|
||||
folderName = `${folderName} ${asin}`;
|
||||
}
|
||||
|
||||
// Build path: [media_dir]/[author]/[title (year) asin]/
|
||||
const titleFolderPath = path.join(mediaDir, sanitizedAuthor, folderName);
|
||||
|
||||
// Check if folder exists
|
||||
try {
|
||||
@@ -185,11 +215,20 @@ export async function deleteRequest(
|
||||
console.log(`[RequestDelete] Deleted media directory: ${titleFolderPath}`);
|
||||
filesDeleted = true;
|
||||
} catch (accessError) {
|
||||
// Folder doesn't exist - that's okay
|
||||
console.log(
|
||||
`[RequestDelete] Media directory not found (already deleted?): ${titleFolderPath}`
|
||||
);
|
||||
filesDeleted = false;
|
||||
// Folder doesn't exist - try without year/ASIN (fallback for older files)
|
||||
const fallbackPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle);
|
||||
try {
|
||||
await fs.access(fallbackPath);
|
||||
await fs.rm(fallbackPath, { recursive: true, force: true });
|
||||
console.log(`[RequestDelete] Deleted media directory (fallback path): ${fallbackPath}`);
|
||||
filesDeleted = true;
|
||||
} catch (fallbackError) {
|
||||
// Neither path exists - that's okay
|
||||
console.log(
|
||||
`[RequestDelete] Media directory not found (tried: ${titleFolderPath}, ${fallbackPath})`
|
||||
);
|
||||
filesDeleted = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -199,7 +238,88 @@ export async function deleteRequest(
|
||||
// Continue with soft delete even if file deletion fails
|
||||
}
|
||||
|
||||
// 4. Soft delete request
|
||||
// 4. Delete from plex_library table and clear audiobook availability
|
||||
// This ensures the book immediately shows as NOT available when searching
|
||||
try {
|
||||
const { getConfigService } = await import('./config.service');
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
// Delete ALL plex_library records matching this audiobook's title and author
|
||||
// This handles cases where there might be duplicate library records
|
||||
// and ensures the book doesn't show as "In Your Library" during searches
|
||||
try {
|
||||
// Find all matching library records (by title/author fuzzy match)
|
||||
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
title: {
|
||||
contains: request.audiobook.title.substring(0, 20),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Filter to exact matches (case-insensitive title and author)
|
||||
const exactMatches = matchingLibraryRecords.filter((record) => {
|
||||
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
|
||||
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
|
||||
return titleMatch && authorMatch;
|
||||
});
|
||||
|
||||
if (exactMatches.length > 0) {
|
||||
// Delete all exact matches
|
||||
const deletePromises = exactMatches.map((record) =>
|
||||
prisma.plexLibrary.delete({ where: { id: record.id } })
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
console.log(
|
||||
`[RequestDelete] Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[RequestDelete] No plex_library records found for "${request.audiobook.title}"`
|
||||
);
|
||||
}
|
||||
} catch (libError) {
|
||||
console.error(
|
||||
`[RequestDelete] Error deleting plex_library records:`,
|
||||
libError instanceof Error ? libError.message : 'Unknown error'
|
||||
);
|
||||
// Continue with deletion even if library cleanup fails
|
||||
}
|
||||
|
||||
// Clear audiobook record linkage
|
||||
const updateData: any = {
|
||||
status: 'requested', // Reset to requested state
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Clear library linkage based on backend mode
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
updateData.absItemId = null;
|
||||
} else {
|
||||
updateData.plexGuid = null;
|
||||
}
|
||||
|
||||
await prisma.audiobook.update({
|
||||
where: { id: request.audiobook.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[RequestDelete] Cleared availability status for audiobook ${request.audiobook.id}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[RequestDelete] Error clearing audiobook status:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
// Continue with deletion even if this fails
|
||||
}
|
||||
|
||||
// 5. Soft delete request
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Path Mapper Utility
|
||||
* Documentation: documentation/phase3/qbittorrent.md
|
||||
*
|
||||
* Handles remote-to-local path mapping for qBittorrent downloads.
|
||||
* Use case: qBittorrent on remote seedbox or different mount points.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export interface PathMappingConfig {
|
||||
enabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
export class PathMapper {
|
||||
/**
|
||||
* Transforms a qBittorrent path using remote-to-local mapping
|
||||
*
|
||||
* Example:
|
||||
* qBittorrent reports: /remote/mnt/d/done/Audiobook.Name
|
||||
* Config: { enabled: true, remotePath: '/remote/mnt/d/done', localPath: '/downloads' }
|
||||
* Returns: /downloads/Audiobook.Name
|
||||
*
|
||||
* @param qbittorrentPath - Path reported by qBittorrent
|
||||
* @param config - Path mapping configuration
|
||||
* @returns Transformed path (or original if mapping disabled/no match)
|
||||
*/
|
||||
static transform(qbittorrentPath: string, config: PathMappingConfig): string {
|
||||
// 1. If mapping disabled, return original
|
||||
if (!config.enabled) {
|
||||
return qbittorrentPath;
|
||||
}
|
||||
|
||||
// 2. Handle empty paths
|
||||
if (!qbittorrentPath || !config.remotePath || !config.localPath) {
|
||||
console.warn('PathMapper: Empty path or config, returning original');
|
||||
return qbittorrentPath;
|
||||
}
|
||||
|
||||
// 3. Normalize paths (handle trailing slashes, backslashes)
|
||||
// Convert all backslashes to forward slashes for consistency
|
||||
const normalizedRemote = this.normalizePath(config.remotePath);
|
||||
const normalizedLocal = this.normalizePath(config.localPath);
|
||||
const normalizedQbPath = this.normalizePath(qbittorrentPath);
|
||||
|
||||
// 4. Check if qBittorrent path starts with remote path
|
||||
if (!normalizedQbPath.startsWith(normalizedRemote)) {
|
||||
console.warn(
|
||||
`PathMapper: Path "${qbittorrentPath}" does not start with remote path "${config.remotePath}". ` +
|
||||
`Returning original path unchanged.`
|
||||
);
|
||||
return qbittorrentPath;
|
||||
}
|
||||
|
||||
// 5. Replace remote prefix with local prefix
|
||||
const relativePath = normalizedQbPath.substring(normalizedRemote.length);
|
||||
|
||||
// Join local path with relative path, ensuring proper path separators
|
||||
const transformedPath = path.join(normalizedLocal, relativePath);
|
||||
|
||||
console.log(`PathMapper: Transformed "${qbittorrentPath}" → "${transformedPath}"`);
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates path mapping configuration
|
||||
*
|
||||
* @param config - Path mapping configuration to validate
|
||||
* @throws Error if paths are invalid (empty, malformed, etc.)
|
||||
*/
|
||||
static validate(config: PathMappingConfig): void {
|
||||
if (!config.enabled) {
|
||||
return; // No validation needed if disabled
|
||||
}
|
||||
|
||||
if (!config.remotePath || config.remotePath.trim() === '') {
|
||||
throw new Error('Remote path cannot be empty when path mapping is enabled');
|
||||
}
|
||||
|
||||
if (!config.localPath || config.localPath.trim() === '') {
|
||||
throw new Error('Local path cannot be empty when path mapping is enabled');
|
||||
}
|
||||
|
||||
// Check for obviously invalid paths
|
||||
const invalidChars = /[<>"|?*]/;
|
||||
if (invalidChars.test(config.remotePath)) {
|
||||
throw new Error('Remote path contains invalid characters');
|
||||
}
|
||||
|
||||
if (invalidChars.test(config.localPath)) {
|
||||
throw new Error('Local path contains invalid characters');
|
||||
}
|
||||
|
||||
// Warn if paths look suspicious (but don't throw)
|
||||
if (config.remotePath === config.localPath) {
|
||||
console.warn('PathMapper: Remote and local paths are identical - path mapping will have no effect');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a file path for consistent comparison
|
||||
* - Converts backslashes to forward slashes
|
||||
* - Removes trailing slashes
|
||||
* - Normalizes redundant separators
|
||||
*
|
||||
* @param filePath - Path to normalize
|
||||
* @returns Normalized path
|
||||
*/
|
||||
private static normalizePath(filePath: string): string {
|
||||
// Convert backslashes to forward slashes
|
||||
let normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Use path.normalize to handle redundant separators and ..
|
||||
normalized = path.normalize(normalized);
|
||||
|
||||
// Convert backslashes again (path.normalize might add them on Windows)
|
||||
normalized = normalized.replace(/\\/g, '/');
|
||||
|
||||
// Remove trailing slash (except for root '/')
|
||||
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,37 @@ export class RankingAlgorithm {
|
||||
const requestTitle = audiobook.title.toLowerCase();
|
||||
const requestAuthor = audiobook.author.toLowerCase();
|
||||
|
||||
// Title matching (0-35 points)
|
||||
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
|
||||
// Extract significant words (filter out common stop words)
|
||||
const stopWords = ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'];
|
||||
|
||||
const extractWords = (text: string, stopList: string[]): string[] => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ') // Remove punctuation
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 0 && !stopList.includes(word));
|
||||
};
|
||||
|
||||
const requestWords = extractWords(requestTitle, stopWords);
|
||||
const torrentWords = extractWords(torrentTitle, stopWords);
|
||||
|
||||
// Calculate word coverage: how many REQUEST words appear in TORRENT
|
||||
if (requestWords.length === 0) {
|
||||
// Edge case: title is only stop words, skip filter
|
||||
// Fall through to normal scoring
|
||||
} else {
|
||||
const matchedWords = requestWords.filter(word => torrentWords.includes(word));
|
||||
const coverage = matchedWords.length / requestWords.length;
|
||||
|
||||
// HARD REQUIREMENT: Must have 80%+ word coverage
|
||||
if (coverage < 0.80) {
|
||||
// Automatic rejection - doesn't contain enough of the requested words
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
||||
let titleScore = 0;
|
||||
if (torrentTitle.includes(requestTitle)) {
|
||||
// Found the title, but is it the complete title or part of a longer one?
|
||||
@@ -224,7 +254,7 @@ export class RankingAlgorithm {
|
||||
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
|
||||
}
|
||||
|
||||
// Author matching (0-15 points)
|
||||
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
|
||||
// Parse requested authors (split on separators, filter out roles)
|
||||
const requestAuthors = requestAuthor
|
||||
.split(/,|&| and | - /)
|
||||
|
||||
Reference in New Issue
Block a user