Add indexer flag bonuses and SSL verify toggle

Implements configurable indexer flag bonuses/penalties for torrent ranking, including UI for admin settings and support in ranking-algorithm. Adds an option to disable SSL certificate verification for qBittorrent connections (for self-signed certs), with UI in both setup and admin settings, and persists the setting. Updates documentation, API routes, and ranking logic to support these features. Also includes minor UI improvements and bug fixes.
This commit is contained in:
kikootwo
2026-01-06 20:10:33 -05:00
parent ca7cac0c88
commit 23881eb670
26 changed files with 921 additions and 141 deletions
+13 -1
View File
@@ -43,11 +43,17 @@ Free, open-source BitTorrent client with comprehensive Web API.
## Config ## Config
**Required (database only, no env fallbacks):** **Required (database only, no env fallbacks):**
- `download_client_url` - qBittorrent Web UI URL - `download_client_url` - qBittorrent Web UI URL (supports HTTP and HTTPS)
- `download_client_username` - qBittorrent username - `download_client_username` - qBittorrent username
- `download_client_password` - qBittorrent password - `download_client_password` - qBittorrent password
- `download_dir` - Download save path (passed to qBittorrent for all torrents) - `download_dir` - Download save path (passed to qBittorrent for all torrents)
**Optional (SSL/TLS):**
- `download_client_disable_ssl_verify` - Disable SSL certificate verification for HTTPS (boolean as string "true"/"false", default: "false")
- Use when connecting to qBittorrent with self-signed certificates
- ⚠️ Security warning: Only use on trusted private networks
- Enhanced error messages guide users when SSL issues detected
**Optional (Remote Path Mapping):** **Optional (Remote Path Mapping):**
- `download_client_remote_path_mapping_enabled` - Enable path mapping (boolean as string "true"/"false") - `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_remote_path` - Remote path prefix from qBittorrent
@@ -164,6 +170,12 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
- PathMapper utility for prefix replacement transformation - PathMapper utility for prefix replacement transformation
- Local path validation during test connection - Local path validation during test connection
- Applied in download completion and import retry processors - Applied in download completion and import retry processors
**11. HTTPS SSL certificate errors** - Users with seedboxes using self-signed certificates or Let's Encrypt couldn't connect. Fixed by:
- Optional SSL verification disable toggle in setup wizard and admin settings
- Custom HTTPS agent with `rejectUnauthorized: false` when enabled
- Enhanced error messages identifying SSL/TLS certificate issues with actionable guidance
- Secure by default (SSL verification enabled), with clear security warnings when disabled
- URL format: `https://qbt.domain.com:443/qbittorrent` fully supported
## Tech Stack ## Tech Stack
+91 -10
View File
@@ -12,19 +12,27 @@ Evaluates and scores torrents to automatically select best audiobook download.
**Stage 1: Word Coverage Filter (MANDATORY)** **Stage 1: Word Coverage Filter (MANDATORY)**
- Extracts significant words from request (filters stop words: "the", "a", "an", "of", "on", "in", "at", "by", "for") - 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 - **Parenthetical/bracketed content is optional**: Content in () [] {} treated as subtitle (may be omitted from torrents)
- **Hard requirement: 80%+ coverage or automatic 0 score** - "We Are Legion (We Are Bob)" → Required: ["we", "are", "legion"], Optional: ["bob"]
- "Title [Series Name]" → Required: ["title"], Optional: ["series", "name"]
- Calculates coverage: % of **required** words found in torrent title
- **Hard requirement: 80%+ coverage of required words or automatic 0 score**
- Example: "The Wild Robot on the Island" → ["wild", "robot", "island"] - Example: "The Wild Robot on the Island" → ["wild", "robot", "island"]
- "The Wild Robot" → ["wild", "robot"] → 2/3 = 67% → **REJECTED** - "The Wild Robot" → ["wild", "robot"] → 2/3 = 67% → **REJECTED**
- "The Wild Robot on the Island" → 3/3 = 100% → **PASSES** - "The Wild Robot on the Island" → 3/3 = 100% → **PASSES**
- Prevents wrong series books from matching - Example: "We Are Legion (We Are Bob)" → Required: ["we", "are", "legion"]
- "Dennis E. Taylor - Bobiverse - 01 - We Are Legion" → 3/3 = 100% → **PASSES**
- Prevents wrong series books from matching while handling common subtitle patterns
**Stage 2: Title Matching (0-35 pts)** **Stage 2: Title Matching (0-35 pts)**
- Only scored if Stage 1 passes - Only scored if Stage 1 passes
- Complete title match (followed by metadata: " by", " [", " -") → 35 pts - Complete title match requirements (both must be true):
- Title is substring but continues with more words → fuzzy similarity (partial credit) - No significant words BEFORE matched title (prevents "This Inevitable Ruin Dungeon Crawler Carl, Book 7")
- Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret" - Followed by metadata markers: " by", " [", " -", " (", " {", " :", ","
- No exact match → fuzzy similarity (partial credit) - Complete match → 35 pts
- Title has prefix/suffix words OR continues with more words → fuzzy similarity (partial credit)
- Prevents series confusion: "The Housemaid" vs "The Housemaid's Secret", "Dungeon Crawler Carl" vs "Book 7"
- No substring match → fuzzy similarity (partial credit)
**Stage 3: Author Matching (0-15 pts)** **Stage 3: Author Matching (0-15 pts)**
- Exact substring match → proportional credit - Exact substring match → proportional credit
@@ -52,25 +60,98 @@ Evaluates and scores torrents to automatically select best audiobook download.
- Deviation → penalty - Deviation → penalty
- Unknown duration: 5 pts (neutral) - Unknown duration: 5 pts (neutral)
## Bonus Points System
**Extensible multiplicative bonus system** for external quality factors:
**Indexer Priority Bonus (configurable 1-25, default: 10)**
- Formula: `bonusPoints = baseScore × (priority / 25)`
- Priority 10/25 (40%) → 95 base score → +38 bonus = 133 final
- Priority 20/25 (80%) → 95 base score → +76 bonus = 171 final
- Priority 25/25 (100%) → 95 base score → +95 bonus = 190 final
- Ensures high-quality torrent from low-priority indexer beats low-quality from high-priority
- Bonus scales with quality (better torrents get more benefit from priority)
**Indexer Flag Bonus (configurable -100% to +100%, default: 0%)**
- Formula: `bonusPoints = baseScore × (modifier / 100)`
- Positive modifiers reward desired flags (e.g., "Freeleech" at +50%)
- +50% modifier → 85 base score → +42.5 bonus = 127.5 final
- Negative modifiers penalize undesired flags (e.g., "Unwanted" at -60%)
- -60% modifier → 85 base score → -51 penalty = 34 final
- Dual threshold filtering:
- Base score must be ≥ 50 (quality minimum)
- Final score must be ≥ 50 (not disqualified by negative bonuses)
- Negative bonuses can disqualify otherwise good torrents
- Flag extraction from Prowlarr API:
- `downloadVolumeFactor: 0` → "Freeleech"
- `downloadVolumeFactor: <1` → "Partial Freeleech"
- `uploadVolumeFactor: >1` → "Double Upload"
- Case-insensitive, whitespace-trimmed matching
- Universal across all indexers (not indexer-specific)
- Multiple flag bonuses stack (additive)
**Future Modifiers (planned):**
- User preferences
- Custom rules
**Final Score Calculation:**
1. Calculate base score (0-100) using standard criteria
2. Calculate bonus modifiers (indexer priority, flag bonuses, etc.)
3. Sum bonus points
4. Final score = base score + bonus points
5. Apply dual threshold filter:
- Base score ≥ 50 (quality minimum)
- Final score ≥ 50 (not disqualified by negative bonuses)
6. Sort by final score (descending), then publish date (descending)
## Tiebreaker Sorting
When multiple torrents have identical final scores:
- **Secondary sort:** Publish date descending (newest first)
- Ensures latest uploads are preferred when quality is equal
- Example: 3 torrents with 171 final score → newest upload ranks #1
## Interface ## Interface
```typescript ```typescript
interface IndexerFlagConfig {
name: string; // Flag name (e.g., "Freeleech")
modifier: number; // -100 to 100 (percentage)
}
interface BonusModifier {
type: 'indexer_priority' | 'indexer_flag' | 'custom';
value: number; // Multiplier (e.g., 0.4 for 40%)
points: number; // Calculated bonus points
reason: string; // Human-readable explanation
}
interface TorrentResult {
// ... existing fields
flags?: string[]; // Extracted flags from Prowlarr API
}
interface RankedTorrent extends TorrentResult { interface RankedTorrent extends TorrentResult {
score: number; score: number; // Base score (0-100)
bonusModifiers: BonusModifier[];
bonusPoints: number; // Sum of all bonus points
finalScore: number; // score + bonusPoints
rank: number; rank: number;
breakdown: { breakdown: {
formatScore: number; formatScore: number;
seederScore: number; seederScore: number;
sizeScore: number; sizeScore: number;
matchScore: number; matchScore: number;
totalScore: number; totalScore: number; // Same as score
notes: string[]; notes: string[];
}; };
} }
function rankTorrents( function rankTorrents(
torrents: TorrentResult[], torrents: TorrentResult[],
audiobook: AudiobookRequest audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>, // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[] // Flag bonus configurations
): RankedTorrent[]; ): RankedTorrent[];
``` ```
+3 -1
View File
@@ -26,8 +26,10 @@ const nextConfig: NextConfig = {
return config; return config;
}, },
// Image optimization // Image optimization - DISABLED because we handle our own thumbnail caching
// in /app/cache/thumbnails/ via the Audible refresh job
images: { images: {
unoptimized: true, // Disable Next.js image optimization
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', protocol: 'https',
+92 -2
View File
@@ -10,6 +10,8 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import Link from 'next/link'; import Link from 'next/link';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
interface PlexLibrary { interface PlexLibrary {
id: string; id: string;
@@ -70,6 +72,7 @@ interface Settings {
url: string; url: string;
username: string; username: string;
password: string; password: string;
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean; remotePathMappingEnabled: boolean;
remotePath: string; remotePath: string;
localPath: string; localPath: string;
@@ -102,6 +105,7 @@ export default function AdminSettings() {
const [plexLibraries, setPlexLibraries] = useState<PlexLibrary[]>([]); const [plexLibraries, setPlexLibraries] = useState<PlexLibrary[]>([]);
const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]); const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]);
const [indexers, setIndexers] = useState<IndexerConfig[]>([]); const [indexers, setIndexers] = useState<IndexerConfig[]>([]);
const [flagConfigs, setFlagConfigs] = useState<IndexerFlagConfig[]>([]);
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]); const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
const [isLocalAdmin, setIsLocalAdmin] = useState(false); const [isLocalAdmin, setIsLocalAdmin] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -294,6 +298,7 @@ export default function AdminSettings() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setIndexers(data.indexers || []); setIndexers(data.indexers || []);
setFlagConfigs(data.flagConfigs || []);
} else { } else {
console.error('Failed to fetch indexers:', response.status); console.error('Failed to fetch indexers:', response.status);
// Don't show error on initial load, only if user explicitly tries to load // Don't show error on initial load, only if user explicitly tries to load
@@ -651,6 +656,7 @@ export default function AdminSettings() {
url: settings.downloadClient.url, url: settings.downloadClient.url,
username: settings.downloadClient.username, username: settings.downloadClient.username,
password: settings.downloadClient.password, password: settings.downloadClient.password,
disableSSLVerify: settings.downloadClient.disableSSLVerify,
remotePathMappingEnabled: settings.downloadClient.remotePathMappingEnabled, remotePathMappingEnabled: settings.downloadClient.remotePathMappingEnabled,
remotePath: settings.downloadClient.remotePath, remotePath: settings.downloadClient.remotePath,
localPath: settings.downloadClient.localPath, localPath: settings.downloadClient.localPath,
@@ -846,12 +852,12 @@ export default function AdminSettings() {
throw new Error('Failed to save Prowlarr settings'); throw new Error('Failed to save Prowlarr settings');
} }
// Save indexer configuration if indexers are loaded // Save indexer configuration and flag configs if indexers are loaded
if (indexers.length > 0) { if (indexers.length > 0) {
const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', { const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ indexers }), body: JSON.stringify({ indexers, flagConfigs }),
}); });
if (!indexersResponse.ok) { if (!indexersResponse.ok) {
@@ -1456,6 +1462,54 @@ export default function AdminSettings() {
</div> </div>
)} )}
</div> </div>
{/* Flag Configuration Section */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Indexer Flag Configuration (Optional)
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure score bonuses or penalties for indexer flags like "Freeleech".
These modifiers apply universally across all indexers and affect final torrent ranking.
</p>
</div>
{flagConfigs.length > 0 && (
<div className="space-y-3 mb-4">
{flagConfigs.map((config, index) => (
<FlagConfigRow
key={index}
config={config}
onChange={(updated) => {
const newConfigs = [...flagConfigs];
newConfigs[index] = updated;
setFlagConfigs(newConfigs);
}}
onRemove={() => {
setFlagConfigs(flagConfigs.filter((_, i) => i !== index));
}}
/>
))}
</div>
)}
<Button
onClick={() => {
setFlagConfigs([...flagConfigs, { name: '', modifier: 0 }]);
}}
variant="outline"
size="sm"
>
+ Add Flag Rule
</Button>
{flagConfigs.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3 italic">
No flag rules configured. Flag bonuses/penalties are optional.
</p>
)}
</div>
</div> </div>
)} )}
@@ -1551,6 +1605,42 @@ export default function AdminSettings() {
/> />
</div> </div>
{/* SSL Verification Toggle */}
{settings.downloadClient.url.startsWith('https') && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="disable-ssl-verify"
checked={settings.downloadClient.disableSSLVerify}
onChange={(e) => {
setSettings({
...settings,
downloadClient: {
...settings.downloadClient,
disableSSLVerify: 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="disable-ssl-verify"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Disable SSL Certificate Verification
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Enable this if you're using a self-signed certificate or getting SSL errors.
<span className="text-yellow-700 dark:text-yellow-500 font-medium"> ⚠️ Only use on trusted private networks.</span>
</p>
</div>
</div>
</div>
)}
{/* Remote Path Mapping */} {/* 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="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"> <div className="flex items-start gap-4">
@@ -17,6 +17,7 @@ export async function PUT(request: NextRequest) {
url, url,
username, username,
password, password,
disableSSLVerify,
remotePathMappingEnabled, remotePathMappingEnabled,
remotePath, remotePath,
localPath, localPath,
@@ -92,6 +93,16 @@ export async function PUT(request: NextRequest) {
}); });
} }
// Save SSL verification setting
await prisma.configuration.upsert({
where: { key: 'download_client_disable_ssl_verify' },
update: { value: disableSSLVerify ? 'true' : 'false' },
create: {
key: 'download_client_disable_ssl_verify',
value: disableSSLVerify ? 'true' : 'false',
},
});
// Save remote path mapping configuration // Save remote path mapping configuration
await prisma.configuration.upsert({ await prisma.configuration.upsert({
where: { key: 'download_client_remote_path_mapping_enabled' }, where: { key: 'download_client_remote_path_mapping_enabled' },
@@ -34,6 +34,10 @@ export async function GET(request: NextRequest) {
const savedConfigStr = await configService.get('prowlarr_indexers'); const savedConfigStr = await configService.get('prowlarr_indexers');
const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : []; const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : [];
// Get saved flag configuration
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes}) // Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes})
const savedIndexersMap = new Map<number, SavedIndexerConfig>( const savedIndexersMap = new Map<number, SavedIndexerConfig>(
savedIndexers.map((idx) => [idx.id, idx]) savedIndexers.map((idx) => [idx.id, idx])
@@ -58,6 +62,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
indexers: indexersWithConfig, indexers: indexersWithConfig,
flagConfigs,
}); });
} catch (error) { } catch (error) {
console.error('[Prowlarr] Failed to fetch indexers:', error); console.error('[Prowlarr] Failed to fetch indexers:', error);
@@ -76,13 +81,13 @@ export async function GET(request: NextRequest) {
/** /**
* PUT /api/admin/settings/prowlarr/indexers * PUT /api/admin/settings/prowlarr/indexers
* Save indexer configuration * Save indexer configuration and flag configs
*/ */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const { indexers } = await req.json(); const { indexers, flagConfigs } = await req.json();
// Filter to only enabled indexers and convert to wizard format // Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers const enabledIndexers = indexers
@@ -97,14 +102,26 @@ export async function PUT(request: NextRequest) {
// Save to configuration (matches wizard format) // Save to configuration (matches wizard format)
const configService = getConfigService(); const configService = getConfigService();
await configService.setMany([ const configUpdates = [
{ {
key: 'prowlarr_indexers', key: 'prowlarr_indexers',
value: JSON.stringify(enabledIndexers), value: JSON.stringify(enabledIndexers),
category: 'indexer', category: 'indexer',
description: 'Prowlarr indexer settings (enabled, priority, seeding time)', description: 'Prowlarr indexer settings (enabled, priority, seeding time)',
}, },
]); ];
// Save flag configs if provided
if (flagConfigs !== undefined) {
configUpdates.push({
key: 'indexer_flag_config',
value: JSON.stringify(flagConfigs),
category: 'indexer',
description: 'Indexer flag bonus/penalty configuration',
});
}
await configService.setMany(configUpdates);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+1
View File
@@ -71,6 +71,7 @@ export async function GET(request: NextRequest) {
url: configMap.get('download_client_url') || '', url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '', username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')), password: maskValue('password', configMap.get('download_client_password')),
disableSSLVerify: configMap.get('download_client_disable_ssl_verify') === 'true',
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'), seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true', remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true',
remotePath: configMap.get('download_client_remote_path') || '', remotePath: configMap.get('download_client_remote_path') || '',
@@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
url, url,
username, username,
password, password,
disableSSLVerify,
remotePathMappingEnabled, remotePathMappingEnabled,
remotePath, remotePath,
localPath, localPath,
@@ -57,7 +58,8 @@ export async function POST(request: NextRequest) {
const version = await QBittorrentService.testConnectionWithCredentials( const version = await QBittorrentService.testConnectionWithCredentials(
url, url,
username, username,
actualPassword actualPassword,
disableSSLVerify || false
); );
// If path mapping enabled, validate local path exists // If path mapping enabled, validate local path exists
@@ -55,6 +55,15 @@ export async function POST(request: NextRequest) {
); );
} }
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
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) : [];
// Search Prowlarr for torrents - ONLY enabled indexers // Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
const searchQuery = title; // Title only - cast wide net const searchQuery = title; // Title only - cast wide net
@@ -76,13 +85,24 @@ export async function POST(request: NextRequest) {
}); });
} }
// Rank torrents using the ranking algorithm // Rank torrents using the ranking algorithm with indexer priorities and flag configs
const rankedResults = rankTorrents(results, { title, author }); const rankedResults = rankTorrents(results, { title, author }, indexerPriorities, flagConfigs);
// Filter out results below minimum score threshold (50/100) // Dual threshold filtering:
const filteredResults = rankedResults.filter(result => result.score >= 50); // 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`); const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
console.log(`[AudiobookSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
}
// Log top 3 results with detailed score breakdown for debugging // Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3); const top3 = filteredResults.slice(0, 3);
@@ -94,12 +114,22 @@ export async function POST(request: NextRequest) {
console.log(`[AudiobookSearch] --------------------------------------------------------`); console.log(`[AudiobookSearch] --------------------------------------------------------`);
top3.forEach((result, index) => { top3.forEach((result, index) => {
console.log(`[AudiobookSearch] ${index + 1}. "${result.title}"`); console.log(`[AudiobookSearch] ${index + 1}. "${result.title}"`);
console.log(`[AudiobookSearch] Indexer: ${result.indexer}`); console.log(`[AudiobookSearch] Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
console.log(`[AudiobookSearch] Total Score: ${result.score.toFixed(1)}/100`); console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Base Score: ${result.score.toFixed(1)}/100`);
console.log(`[AudiobookSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`); console.log(`[AudiobookSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
console.log(`[AudiobookSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`); console.log(`[AudiobookSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[AudiobookSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`); console.log(`[AudiobookSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
console.log(`[AudiobookSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`); console.log(`[AudiobookSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
result.bonusModifiers.forEach(mod => {
console.log(`[AudiobookSearch] - ${mod.reason}: +${mod.points.toFixed(1)}`);
});
}
console.log(`[AudiobookSearch] `);
console.log(`[AudiobookSearch] Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) { if (result.breakdown.notes.length > 0) {
console.log(`[AudiobookSearch] Notes: ${result.breakdown.notes.join(', ')}`); console.log(`[AudiobookSearch] Notes: ${result.breakdown.notes.join(', ')}`);
} }
@@ -82,6 +82,15 @@ export async function POST(
); );
} }
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
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) : [];
// Search Prowlarr for torrents - ONLY enabled indexers // Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
// Use custom title if provided, otherwise use audiobook's title // Use custom title if provided, otherwise use audiobook's title
@@ -107,17 +116,28 @@ export async function POST(
}); });
} }
// Rank torrents using the ranking algorithm // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query) // Always use the audiobook's title/author for ranking (not custom search query)
const rankedResults = rankTorrents(results, { const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title, title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
}); }, indexerPriorities, flagConfigs);
// Filter out results below minimum score threshold (50/100) // Dual threshold filtering:
const filteredResults = rankedResults.filter(result => result.score >= 50); // 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`); const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
console.log(`[InteractiveSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
}
// Log top 3 results with detailed score breakdown for debugging // Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3); const top3 = filteredResults.slice(0, 3);
@@ -130,12 +150,22 @@ export async function POST(
console.log(`[InteractiveSearch] --------------------------------------------------------`); console.log(`[InteractiveSearch] --------------------------------------------------------`);
top3.forEach((result, index) => { top3.forEach((result, index) => {
console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`); console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`);
console.log(`[InteractiveSearch] Indexer: ${result.indexer}`); console.log(`[InteractiveSearch] Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
console.log(`[InteractiveSearch] Total Score: ${result.score.toFixed(1)}/100`); console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Base Score: ${result.score.toFixed(1)}/100`);
console.log(`[InteractiveSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`); console.log(`[InteractiveSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
console.log(`[InteractiveSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`); console.log(`[InteractiveSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[InteractiveSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`); console.log(`[InteractiveSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
console.log(`[InteractiveSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`); console.log(`[InteractiveSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
result.bonusModifiers.forEach(mod => {
console.log(`[InteractiveSearch] - ${mod.reason}: +${mod.points.toFixed(1)}`);
});
}
console.log(`[InteractiveSearch] `);
console.log(`[InteractiveSearch] Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) { if (result.breakdown.notes.length > 0) {
console.log(`[InteractiveSearch] Notes: ${result.breakdown.notes.join(', ')}`); console.log(`[InteractiveSearch] Notes: ${result.breakdown.notes.join(', ')}`);
} }
+9
View File
@@ -356,6 +356,15 @@ export async function POST(request: NextRequest) {
create: { key: 'download_client_password', value: downloadClient.password }, create: { key: 'download_client_password', value: downloadClient.password },
}); });
await prisma.configuration.upsert({
where: { key: 'download_client_disable_ssl_verify' },
update: { value: downloadClient.disableSSLVerify ? 'true' : 'false' },
create: {
key: 'download_client_disable_ssl_verify',
value: downloadClient.disableSSLVerify ? 'true' : 'false',
},
});
// Remote path mapping configuration // Remote path mapping configuration
await prisma.configuration.upsert({ await prisma.configuration.upsert({
where: { key: 'download_client_remote_path_mapping_enabled' }, where: { key: 'download_client_remote_path_mapping_enabled' },
@@ -8,7 +8,7 @@ import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { type, url, username, password } = await request.json(); const { type, url, username, password, disableSSLVerify } = await request.json();
if (!type || !url || !username || !password) { if (!type || !url || !username || !password) {
return NextResponse.json( return NextResponse.json(
@@ -28,7 +28,8 @@ export async function POST(request: NextRequest) {
const version = await QBittorrentService.testConnectionWithCredentials( const version = await QBittorrentService.testConnectionWithCredentials(
url, url,
username, username,
password password,
disableSSLVerify || false
); );
return NextResponse.json({ return NextResponse.json({
-24
View File
@@ -808,30 +808,6 @@ function LoginContent() {
</> </>
)} )}
</div> </div>
{/* Footer info */}
<div className="mt-8 text-center text-sm text-gray-500">
<p>
Powered by{' '}
<a
href="https://www.plex.tv"
target="_blank"
rel="noopener noreferrer"
className="text-orange-400 hover:text-orange-300 transition-colors"
>
Plex
</a>
{' '}&{' '}
<a
href="https://www.audible.com"
target="_blank"
rel="noopener noreferrer"
className="text-orange-400 hover:text-orange-300 transition-colors"
>
Audible
</a>
</p>
</div>
</div> </div>
</main> </main>
+4 -21
View File
@@ -19,6 +19,7 @@ export default function HomePage() {
// Refs for auto-scrolling to section tops // Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null); const popularSectionRef = useRef<HTMLElement>(null);
const newReleasesSectionRef = useRef<HTMLElement>(null); const newReleasesSectionRef = useRef<HTMLElement>(null);
const footerRef = useRef<HTMLElement>(null);
const { const {
audiobooks: popular, audiobooks: popular,
@@ -139,30 +140,10 @@ export default function HomePage() {
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16"> <footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="container mx-auto px-4 py-6 max-w-7xl"> <div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="text-center text-sm text-gray-600 dark:text-gray-400"> <div className="text-center text-sm text-gray-600 dark:text-gray-400">
<p>ReadMeABook - Audiobook Library Management System</p> <p>ReadMeABook - Audiobook Library Management System</p>
<p className="mt-1">
Powered by{' '}
<a
href="https://www.plex.tv"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Plex
</a>
{' '}&{' '}
<a
href="https://www.audible.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Audible
</a>
</p>
</div> </div>
</div> </div>
</footer> </footer>
@@ -173,6 +154,7 @@ export default function HomePage() {
totalPages={popularTotalPages} totalPages={popularTotalPages}
onPageChange={handlePopularPageChange} onPageChange={handlePopularPageChange}
sectionRef={popularSectionRef} sectionRef={popularSectionRef}
footerRef={footerRef}
label="Popular Audiobooks" label="Popular Audiobooks"
/> />
<StickyPagination <StickyPagination
@@ -180,6 +162,7 @@ export default function HomePage() {
totalPages={newReleasesTotalPages} totalPages={newReleasesTotalPages}
onPageChange={handleNewReleasesPageChange} onPageChange={handleNewReleasesPageChange}
sectionRef={newReleasesSectionRef} sectionRef={newReleasesSectionRef}
footerRef={footerRef}
label="New Releases" label="New Releases"
/> />
</div> </div>
+4
View File
@@ -77,6 +77,7 @@ interface SetupState {
downloadClientUrl: string; downloadClientUrl: string;
downloadClientUsername: string; downloadClientUsername: string;
downloadClientPassword: string; downloadClientPassword: string;
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean; remotePathMappingEnabled: boolean;
remotePath: string; remotePath: string;
localPath: string; localPath: string;
@@ -145,6 +146,7 @@ export default function SetupWizard() {
downloadClientUrl: '', downloadClientUrl: '',
downloadClientUsername: 'admin', downloadClientUsername: 'admin',
downloadClientPassword: '', downloadClientPassword: '',
disableSSLVerify: false,
remotePathMappingEnabled: false, remotePathMappingEnabled: false,
remotePath: '', remotePath: '',
localPath: '', localPath: '',
@@ -217,6 +219,7 @@ export default function SetupWizard() {
url: state.downloadClientUrl, url: state.downloadClientUrl,
username: state.downloadClientUsername, username: state.downloadClientUsername,
password: state.downloadClientPassword, password: state.downloadClientPassword,
disableSSLVerify: state.disableSSLVerify,
remotePathMappingEnabled: state.remotePathMappingEnabled, remotePathMappingEnabled: state.remotePathMappingEnabled,
remotePath: state.remotePath, remotePath: state.remotePath,
localPath: state.localPath, localPath: state.localPath,
@@ -503,6 +506,7 @@ export default function SetupWizard() {
downloadClientUrl={state.downloadClientUrl} downloadClientUrl={state.downloadClientUrl}
downloadClientUsername={state.downloadClientUsername} downloadClientUsername={state.downloadClientUsername}
downloadClientPassword={state.downloadClientPassword} downloadClientPassword={state.downloadClientPassword}
disableSSLVerify={state.disableSSLVerify}
remotePathMappingEnabled={state.remotePathMappingEnabled} remotePathMappingEnabled={state.remotePathMappingEnabled}
remotePath={state.remotePath} remotePath={state.remotePath}
localPath={state.localPath} localPath={state.localPath}
@@ -14,6 +14,7 @@ interface DownloadClientStepProps {
downloadClientUrl: string; downloadClientUrl: string;
downloadClientUsername: string; downloadClientUsername: string;
downloadClientPassword: string; downloadClientPassword: string;
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean; remotePathMappingEnabled: boolean;
remotePath: string; remotePath: string;
localPath: string; localPath: string;
@@ -27,6 +28,7 @@ export function DownloadClientStep({
downloadClientUrl, downloadClientUrl,
downloadClientUsername, downloadClientUsername,
downloadClientPassword, downloadClientPassword,
disableSSLVerify,
remotePathMappingEnabled, remotePathMappingEnabled,
remotePath, remotePath,
localPath, localPath,
@@ -54,6 +56,7 @@ export function DownloadClientStep({
url: downloadClientUrl, url: downloadClientUrl,
username: downloadClientUsername, username: downloadClientUsername,
password: downloadClientPassword, password: downloadClientPassword,
disableSSLVerify,
remotePathMappingEnabled, remotePathMappingEnabled,
remotePath, remotePath,
localPath, localPath,
@@ -185,6 +188,33 @@ export function DownloadClientStep({
/> />
</div> </div>
{/* SSL Verification Toggle */}
{downloadClientUrl.startsWith('https') && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-800">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="disable-ssl-verify-setup"
checked={disableSSLVerify}
onChange={(e) => onUpdate('disableSSLVerify', 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="disable-ssl-verify-setup"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Disable SSL Certificate Verification
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Enable this if you're using a self-signed certificate or getting SSL errors.
<span className="text-yellow-700 dark:text-yellow-500 font-medium"> Only use on trusted private networks.</span>
</p>
</div>
</div>
</div>
)}
{/* Remote Path Mapping */} {/* 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="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"> <div className="flex items-start gap-4">
+143
View File
@@ -0,0 +1,143 @@
/**
* Component: Flag Configuration Row
* Documentation: documentation/phase3/ranking-algorithm.md
*
* Allows configuration of indexer flag bonuses/penalties with visual slider feedback
*/
'use client';
import React from 'react';
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
import { TrashIcon } from '@heroicons/react/24/outline';
interface FlagConfigRowProps {
config: IndexerFlagConfig;
onChange: (config: IndexerFlagConfig) => void;
onRemove: () => void;
}
export function FlagConfigRow({ config, onChange, onRemove }: FlagConfigRowProps) {
const exampleBase = 85;
const bonusPoints = exampleBase * (config.modifier / 100);
const finalScore = exampleBase + bonusPoints;
// Get color for modifier percentage display
const getModifierColor = (modifier: number): string => {
if (modifier < -50) return 'text-red-700 dark:text-red-400';
if (modifier < 0) return 'text-red-600 dark:text-red-500';
if (modifier === 0) return 'text-gray-600 dark:text-gray-400';
if (modifier > 50) return 'text-green-700 dark:text-green-400';
return 'text-green-600 dark:text-green-500';
};
// Get slider gradient based on current value
const getSliderBackground = (modifier: number): string => {
const normalizedPosition = ((modifier + 100) / 200) * 100; // -100 to 100 → 0% to 100%
// Create gradient that fills from left up to current position
// Red on left, yellow in middle, green on right
return `linear-gradient(to right,
#ef4444 0%,
#ef4444 ${Math.max(0, normalizedPosition - 5)}%,
#fbbf24 50%,
#10b981 ${Math.min(100, normalizedPosition + 5)}%,
#10b981 100%)`;
};
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<div className="flex items-start gap-4">
{/* Flag Name Input */}
<div className="flex-shrink-0 w-48">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Flag Name
</label>
<input
type="text"
value={config.name}
onChange={(e) => onChange({ ...config, name: e.target.value })}
placeholder="e.g. Freeleech"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
{/* Score Modifier Slider */}
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Score Modifier
</label>
<div className="flex items-center gap-3 mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400 w-12 text-right">-100%</span>
<div className="flex-1 relative">
<input
type="range"
min="-100"
max="100"
step="5"
value={config.modifier}
onChange={(e) => onChange({ ...config, modifier: parseInt(e.target.value) })}
className="w-full h-2 rounded-lg appearance-none cursor-pointer slider-custom"
style={{
background: getSliderBackground(config.modifier),
}}
/>
<style jsx>{`
.slider-custom::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
border: 2px solid #3b82f6;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.slider-custom::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
border: 2px solid #3b82f6;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
`}</style>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 w-12">+100%</span>
<span className={`text-sm font-bold min-w-[60px] text-right ${getModifierColor(config.modifier)}`}>
{config.modifier > 0 ? '+' : ''}{config.modifier}%
</span>
</div>
{/* Dynamic Help Text */}
<p className="text-xs text-gray-600 dark:text-gray-400">
Example: Base score of {exampleBase} with "{config.name || 'this flag'}"
{' → '}
<span className={bonusPoints >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{bonusPoints >= 0 ? '+' : ''}{bonusPoints.toFixed(1)} bonus points
</span>
{bonusPoints < 0 && finalScore < 50 && (
<span className="text-red-600 dark:text-red-400 font-medium">
{' '} Would disqualify (final: {finalScore.toFixed(1)} &lt; 50)
</span>
)}
</p>
</div>
{/* Remove Button */}
<button
onClick={onRemove}
className="flex-shrink-0 mt-7 p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Remove flag rule"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</div>
);
}
+4 -6
View File
@@ -134,9 +134,10 @@ export function AudiobookCard({
</p> </p>
)} )}
{/* Metadata Row */} {/* Metadata Row - Fixed height for alignment */}
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 h-5">
{audiobook.rating && ( {/* Rating - Only show if > 0 (0 means no rating) */}
{audiobook.rating && audiobook.rating > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<svg className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20"> <svg className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
@@ -144,9 +145,6 @@ export function AudiobookCard({
<span>{audiobook.rating.toFixed(1)}</span> <span>{audiobook.rating.toFixed(1)}</span>
</div> </div>
)} )}
{audiobook.durationMinutes && (
<span>{formatDuration(audiobook.durationMinutes)}</span>
)}
</div> </div>
{/* Status or Action */} {/* Status or Action */}
@@ -242,10 +242,10 @@ export function AudiobookDetailsModal({
{/* Metadata Grid */} {/* Metadata Grid */}
<div className="grid grid-cols-2 gap-4 pt-2"> <div className="grid grid-cols-2 gap-4 pt-2">
{/* Rating */} {/* Rating - Always show header, display 'Not Found' if no rating */}
{audiobook.rating && (
<div> <div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Rating</p> <p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Rating</p>
{audiobook.rating && audiobook.rating > 0 ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
@@ -266,8 +266,10 @@ export function AudiobookDetailsModal({
{Number(audiobook.rating).toFixed(1)} {Number(audiobook.rating).toFixed(1)}
</span> </span>
</div> </div>
</div> ) : (
<p className="text-gray-500 dark:text-gray-400 italic">Not Found</p>
)} )}
</div>
{/* Duration */} {/* Duration */}
{audiobook.durationMinutes && ( {audiobook.durationMinutes && (
@@ -9,7 +9,7 @@ import React, { useState } from 'react';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { ConfirmModal } from '@/components/ui/ConfirmModal'; import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { TorrentResult } from '@/lib/utils/ranking-algorithm'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests'; import { useInteractiveSearch, useSelectTorrent, useSearchTorrents, useRequestWithTorrent } from '@/lib/hooks/useRequests';
import { Audiobook } from '@/lib/hooks/useAudiobooks'; import { Audiobook } from '@/lib/hooks/useAudiobooks';
@@ -41,7 +41,7 @@ export function InteractiveTorrentSearchModal({
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
const [results, setResults] = useState<(TorrentResult & { rank: number; qualityScore?: number })[]>([]); const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null); const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title); const [searchTitle, setSearchTitle] = useState(audiobook.title);
@@ -200,25 +200,28 @@ export function InteractiveTorrentSearchModal({
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900"> <thead className="bg-gray-50 dark:bg-gray-900">
<tr> <tr>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> <th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12">
# #
</th> </th>
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> <th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Title Title
</th> </th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell"> <th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell w-24">
Size Size
</th> </th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> <th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Base quality score (0-100): Title/Author match (50) + Format (25) + Seeders (15) + Size (10)">
Score Score
</th> </th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell"> <th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Bonus points from indexer priority and other modifiers">
Bonus
</th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell w-20">
Seeds Seeds
</th> </th>
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell"> <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 Indexer
</th> </th>
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> <th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
Action Action
</th> </th>
</tr> </tr>
@@ -230,7 +233,7 @@ export function InteractiveTorrentSearchModal({
{result.rank} {result.rank}
</td> </td>
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100"> <td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
<div className="max-w-xs lg:max-w-md truncate"> <div className="truncate">
<a <a
href={result.guid} href={result.guid}
target="_blank" target="_blank"
@@ -259,10 +262,13 @@ export function InteractiveTorrentSearchModal({
{formatSize(result.size)} {formatSize(result.size)}
</td> </td>
<td className="px-2 py-3 whitespace-nowrap text-sm"> <td className="px-2 py-3 whitespace-nowrap text-sm">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(result.qualityScore || 0)}`}> <span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
{result.qualityScore || 0} {Math.round(result.score)}
</span> </span>
</td> </td>
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{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"> <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"> <span className="flex items-center gap-1">
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
+27 -1
View File
@@ -14,6 +14,7 @@ interface StickyPaginationProps {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>; sectionRef: React.RefObject<HTMLElement | null>;
label: string; // e.g., "Popular Audiobooks" label: string; // e.g., "Popular Audiobooks"
footerRef?: React.RefObject<HTMLElement | null>; // Optional footer ref to avoid overlap
} }
export function StickyPagination({ export function StickyPagination({
@@ -22,8 +23,10 @@ export function StickyPagination({
onPageChange, onPageChange,
sectionRef, sectionRef,
label, label,
footerRef,
}: StickyPaginationProps) { }: StickyPaginationProps) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isFooterVisible, setIsFooterVisible] = useState(false);
const [jumpPage, setJumpPage] = useState(currentPage.toString()); const [jumpPage, setJumpPage] = useState(currentPage.toString());
// Update jump page input when current page changes externally // Update jump page input when current page changes externally
@@ -51,6 +54,26 @@ export function StickyPagination({
return () => observer.disconnect(); return () => observer.disconnect();
}, [sectionRef]); }, [sectionRef]);
// Footer observer to hide pagination when footer is visible
useEffect(() => {
if (!footerRef?.current) return;
const observer = new IntersectionObserver(
([entry]) => {
// Hide pagination when footer is in viewport
setIsFooterVisible(entry.isIntersecting);
},
{
threshold: [0, 0.1],
rootMargin: '0px',
}
);
observer.observe(footerRef.current);
return () => observer.disconnect();
}, [footerRef]);
if (totalPages <= 1) { if (totalPages <= 1) {
return null; return null;
} }
@@ -78,10 +101,13 @@ export function StickyPagination({
} }
}; };
// Final visibility: show when section is visible AND footer is not visible
const shouldShow = isVisible && !isFooterVisible;
return ( return (
<div <div
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${ className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0' shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
}`} }`}
> >
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5"> <div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
+8
View File
@@ -174,12 +174,16 @@ export class AudibleService {
const coverArtUrl = $el.find('img').attr('src') || ''; const coverArtUrl = $el.find('img').attr('src') || '';
const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: authorText.replace('By:', '').replace('Written by:', '').trim(),
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
}); });
foundOnPage++; foundOnPage++;
@@ -249,6 +253,9 @@ export class AudibleService {
const runtimeText = $el.find('.runtimeLabel').text().trim(); const runtimeText = $el.find('.runtimeLabel').text().trim();
const durationMinutes = this.parseRuntime(runtimeText); const durationMinutes = this.parseRuntime(runtimeText);
const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
@@ -256,6 +263,7 @@ export class AudibleService {
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes, durationMinutes,
rating,
}); });
}); });
+66
View File
@@ -43,6 +43,7 @@ export interface IndexerStats {
interface ProwlarrSearchResult { interface ProwlarrSearchResult {
guid: string; guid: string;
indexer: string; indexer: string;
indexerId?: number;
title: string; title: string;
size: number; size: number;
seeders: number; seeders: number;
@@ -51,6 +52,10 @@ interface ProwlarrSearchResult {
downloadUrl: string; downloadUrl: string;
infoHash?: string; infoHash?: string;
categories?: number[]; categories?: number[];
downloadVolumeFactor?: number;
uploadVolumeFactor?: number;
indexerFlags?: string[] | number[]; // Can be string names or numeric IDs
[key: string]: any; // Allow any additional fields from Prowlarr API
} }
export class ProwlarrService { export class ProwlarrService {
@@ -99,6 +104,11 @@ export class ProwlarrService {
const response = await this.client.get('/search', { params }); const response = await this.client.get('/search', { params });
// Debug: Log first raw result to see structure
if (response.data.length > 0) {
console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2));
}
// Transform Prowlarr results to our format // Transform Prowlarr results to our format
const results = response.data const results = response.data
.map((result: ProwlarrSearchResult) => this.transformResult(result)) .map((result: ProwlarrSearchResult) => this.transformResult(result))
@@ -232,6 +242,7 @@ export class ProwlarrService {
const result: TorrentResult = { const result: TorrentResult = {
indexer: item.prowlarrindexer?.['#text'] || item.prowlarrindexer || 'Unknown', indexer: item.prowlarrindexer?.['#text'] || item.prowlarrindexer || 'Unknown',
indexerId: indexerId,
title: item.title || '', title: item.title || '',
size: parseInt(item.size || '0', 10), size: parseInt(item.size || '0', 10),
seeders, seeders,
@@ -296,8 +307,12 @@ export class ProwlarrService {
// Extract metadata from title // Extract metadata from title
const metadata = this.extractMetadata(result.title); const metadata = this.extractMetadata(result.title);
// Extract flags from result
const flags = this.extractFlags(result);
return { return {
indexer: result.indexer, indexer: result.indexer,
indexerId: result.indexerId,
title: result.title, title: result.title,
size: result.size, size: result.size,
seeders: result.seeders, seeders: result.seeders,
@@ -309,6 +324,7 @@ export class ProwlarrService {
format: metadata.format, format: metadata.format,
bitrate: metadata.bitrate, bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters, hasChapters: metadata.hasChapters,
flags: flags.length > 0 ? flags : undefined,
}; };
} catch (error) { } catch (error) {
console.error('Failed to transform result:', result, error); console.error('Failed to transform result:', result, error);
@@ -316,6 +332,56 @@ export class ProwlarrService {
} }
} }
/**
* Extract indexer flags from Prowlarr result
*/
private extractFlags(result: ProwlarrSearchResult): string[] {
const flags: string[] = [];
// Primary method: Check for indexerFlags array (can be strings or numbers)
if (result.indexerFlags && Array.isArray(result.indexerFlags)) {
result.indexerFlags.forEach(flag => {
if (typeof flag === 'string' && flag.trim()) {
flags.push(flag.trim());
}
// Skip numeric flags - we can't map those to user-friendly names without indexer-specific mapping
});
}
// Also check for common alternative field names Prowlarr might use
const possibleFlagFields = ['flags', 'tags', 'labels'];
for (const fieldName of possibleFlagFields) {
const fieldValue = result[fieldName];
if (fieldValue && Array.isArray(fieldValue)) {
fieldValue.forEach((flag: any) => {
if (typeof flag === 'string' && flag.trim() && !flags.includes(flag.trim())) {
flags.push(flag.trim());
}
});
}
}
// Fallback: Derive flags from volume factors only if no flags were found
if (flags.length === 0) {
if (result.downloadVolumeFactor !== undefined && result.downloadVolumeFactor === 0) {
flags.push('Freeleech');
} else if (result.downloadVolumeFactor !== undefined && result.downloadVolumeFactor < 1) {
flags.push('Partial Freeleech');
}
if (result.uploadVolumeFactor !== undefined && result.uploadVolumeFactor > 1) {
flags.push('Double Upload');
}
}
// Log detected flags for debugging
if (flags.length > 0) {
console.log(`[Prowlarr] ✓ Detected flags for "${result.title.substring(0, 50)}...": [${flags.join(', ')}]`);
}
return flags;
}
/** /**
* Extract audiobook metadata from torrent title * Extract audiobook metadata from torrent title
*/ */
+113 -6
View File
@@ -4,6 +4,7 @@
*/ */
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
import https from 'https';
import * as parseTorrentModule from 'parse-torrent'; import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data'; import FormData from 'form-data';
@@ -80,23 +81,36 @@ export class QBittorrentService {
private cookie?: string; private cookie?: string;
private defaultSavePath: string; private defaultSavePath: string;
private defaultCategory: string; private defaultCategory: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
constructor( constructor(
baseUrl: string, baseUrl: string,
username: string, username: string,
password: string, password: string,
defaultSavePath: string = '/downloads', defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook' defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false
) { ) {
this.baseUrl = baseUrl.replace(/\/$/, ''); this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username; this.username = username;
this.password = password; this.password = password;
this.defaultSavePath = defaultSavePath; this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory; this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
// Create HTTPS agent if SSL verification is disabled
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
console.log('[qBittorrent] SSL certificate verification disabled');
}
this.client = axios.create({ this.client = axios.create({
baseURL: `${this.baseUrl}/api/v2`, baseURL: `${this.baseUrl}/api/v2`,
timeout: 30000, timeout: 30000,
httpsAgent: this.httpsAgent,
}); });
} }
@@ -113,6 +127,7 @@ export class QBittorrentService {
}), }),
{ {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
httpsAgent: this.httpsAgent,
} }
); );
@@ -660,35 +675,123 @@ export class QBittorrentService {
static async testConnectionWithCredentials( static async testConnectionWithCredentials(
url: string, url: string,
username: string, username: string,
password: string password: string,
disableSSLVerify: boolean = false
): Promise<string> { ): Promise<string> {
const baseUrl = url.replace(/\/$/, ''); const baseUrl = url.replace(/\/$/, '');
// Create HTTPS agent if SSL verification is disabled
let httpsAgent: https.Agent | undefined;
if (disableSSLVerify && baseUrl.startsWith('https')) {
httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
console.log('[qBittorrent] SSL certificate verification disabled for test connection');
}
try { try {
const response = await axios.post( const response = await axios.post(
`${baseUrl}/api/v2/auth/login`, `${baseUrl}/api/v2/auth/login`,
new URLSearchParams({ username, password }), new URLSearchParams({ username, password }),
{ {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
httpsAgent,
} }
); );
// Get version to confirm connection // Get version to confirm connection
const cookies = response.headers['set-cookie']; const cookies = response.headers['set-cookie'];
if (!cookies || cookies.length === 0) { if (!cookies || cookies.length === 0) {
throw new Error('Failed to authenticate'); throw new Error('Failed to authenticate - no session cookie received');
} }
const cookie = cookies[0].split(';')[0]; const cookie = cookies[0].split(';')[0];
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, { const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
headers: { Cookie: cookie }, headers: { Cookie: cookie },
httpsAgent,
}); });
return versionResponse.data || 'Connected'; return versionResponse.data || 'Connected';
} catch (error) { } catch (error) {
console.error('qBittorrent connection test failed:', error); console.error('[qBittorrent] Connection test failed:', error);
throw new Error('Failed to connect to qBittorrent');
// Enhanced error messages for common issues
if (axios.isAxiosError(error)) {
const code = error.code;
const status = error.response?.status;
const url = error.config?.url;
// SSL/TLS certificate errors
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT') {
throw new Error(
`SSL certificate verification failed: self-signed certificate detected. ` +
`If you trust this server, enable "Disable SSL Verification" below.`
);
}
if (code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
throw new Error(
`SSL certificate verification failed: unable to verify certificate chain. ` +
`If you trust this server, enable "Disable SSL Verification" below.`
);
}
if (code === 'CERT_HAS_EXPIRED') {
throw new Error(
`SSL certificate verification failed: certificate has expired. ` +
`Update the certificate or enable "Disable SSL Verification" below.`
);
}
if (code?.includes('CERT') || code?.includes('SSL') || code?.includes('TLS')) {
throw new Error(
`SSL certificate verification failed (${code}). ` +
`If you trust this server, enable "Disable SSL Verification" below.`
);
}
// Connection errors
if (code === 'ECONNREFUSED') {
throw new Error(
`Connection refused. Check if qBittorrent is running and accessible at: ${baseUrl}`
);
}
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
throw new Error(
`Connection timeout. Verify the URL is correct and the server is reachable: ${baseUrl}`
);
}
if (code === 'ENOTFOUND') {
throw new Error(
`Host not found. Verify the domain/IP address is correct: ${baseUrl}`
);
}
// HTTP status errors
if (status === 401 || status === 403) {
throw new Error(
`Authentication failed (HTTP ${status}). Check your username and password.`
);
}
if (status === 404) {
throw new Error(
`qBittorrent Web UI not found (HTTP 404). Verify the URL path is correct: ${baseUrl}`
);
}
if (status && status >= 500) {
throw new Error(
`qBittorrent server error (HTTP ${status}). Check server logs.`
);
}
// Generic axios error with more context
throw new Error(
`Failed to connect to qBittorrent at ${baseUrl}: ${error.message}`
);
}
// Non-axios error
throw new Error(
error instanceof Error ? error.message : 'Failed to connect to qBittorrent'
);
} }
} }
@@ -772,6 +875,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
'download_client_username', 'download_client_username',
'download_client_password', 'download_client_password',
'download_dir', 'download_dir',
'download_client_disable_ssl_verify',
]); ]);
console.log('[qBittorrent] Config loaded:', { console.log('[qBittorrent] Config loaded:', {
@@ -779,6 +883,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
hasUsername: !!config.download_client_username, hasUsername: !!config.download_client_username,
hasPassword: !!config.download_client_password, hasPassword: !!config.download_client_password,
hasPath: !!config.download_dir, hasPath: !!config.download_dir,
disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
}); });
// Validate all required fields are present (no env var fallback) // Validate all required fields are present (no env var fallback)
@@ -808,6 +913,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
const username = config.download_client_username as string; const username = config.download_client_username as string;
const password = config.download_client_password as string; const password = config.download_client_password as string;
const savePath = config.download_dir as string; const savePath = config.download_dir as string;
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
console.log('[qBittorrent] Creating service instance...'); console.log('[qBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService( qbittorrentService = new QBittorrentService(
@@ -815,7 +921,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
username, username,
password, password,
savePath, savePath,
'readmeabook' 'readmeabook',
disableSSLVerify
); );
// Test connection // Test connection
@@ -47,6 +47,15 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
throw new Error('No indexers enabled. Please enable at least one indexer in settings.'); throw new Error('No indexers enabled. Please enable at least one indexer in settings.');
} }
// Build indexer priorities map (indexerId -> priority 1-25, default 10)
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) : [];
await logger?.info(`Searching ${enabledIndexerIds.length} enabled indexers`); await logger?.info(`Searching ${enabledIndexerIds.length} enabled indexers`);
// Get Prowlarr service // Get Prowlarr service
@@ -91,17 +100,28 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get ranking algorithm // Get ranking algorithm
const ranker = getRankingAlgorithm(); const ranker = getRankingAlgorithm();
// Rank results // Rank results with indexer priorities and flag configs
const rankedResults = ranker.rankTorrents(searchResults, { const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title, title: audiobook.title,
author: audiobook.author, author: audiobook.author,
durationMinutes: undefined, // We don't have duration from Audible durationMinutes: undefined, // We don't have duration from Audible
}); }, indexerPriorities, flagConfigs);
// Filter out results below minimum score threshold (50/100) // Dual threshold filtering:
const filteredResults = rankedResults.filter(result => result.score >= 50); // 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`); const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
await logger?.info(`${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
}
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
// No quality results found - queue for re-search instead of failing // No quality results found - queue for re-search instead of failing
@@ -137,8 +157,22 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
for (let i = 0; i < top3.length; i++) { for (let i = 0; i < top3.length; i++) {
const result = top3[i]; const result = top3[i];
await logger?.info(`${i + 1}. "${result.title}"`); await logger?.info(`${i + 1}. "${result.title}"`);
await logger?.info(` Indexer: ${result.indexer}`); await logger?.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
await logger?.info(` Total: ${result.score.toFixed(1)}/100 | Match: ${result.breakdown.matchScore.toFixed(1)}/50 | Format: ${result.breakdown.formatScore.toFixed(1)}/25 | Seeders: ${result.breakdown.seederScore.toFixed(1)}/15 | Size: ${result.breakdown.sizeScore.toFixed(1)}/10`); await logger?.info(``);
await logger?.info(` Base Score: ${result.score.toFixed(1)}/100`);
await logger?.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
await logger?.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
await logger?.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
await logger?.info(` - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10`);
await logger?.info(``);
await logger?.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
if (result.bonusModifiers.length > 0) {
for (const mod of result.bonusModifiers) {
await logger?.info(` - ${mod.reason}: +${mod.points.toFixed(1)}`);
}
}
await logger?.info(``);
await logger?.info(` Final Score: ${result.finalScore.toFixed(1)}`);
if (result.breakdown.notes.length > 0) { if (result.breakdown.notes.length > 0) {
await logger?.info(` Notes: ${result.breakdown.notes.join(', ')}`); await logger?.info(` Notes: ${result.breakdown.notes.join(', ')}`);
} }
@@ -147,7 +181,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
} }
} }
await logger?.info(`========================================================`); await logger?.info(`========================================================`);
await logger?.info(`Selected best result: ${bestResult.title} (score: ${bestResult.score.toFixed(1)}/100)`); await logger?.info(`Selected best result: ${bestResult.title} (final score: ${bestResult.finalScore.toFixed(1)})`);
// Trigger download job with best result // Trigger download job with best result
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
+133 -22
View File
@@ -7,6 +7,7 @@ import { compareTwoStrings } from 'string-similarity';
export interface TorrentResult { export interface TorrentResult {
indexer: string; indexer: string;
indexerId?: number;
title: string; title: string;
size: number; size: number;
seeders: number; seeders: number;
@@ -18,6 +19,7 @@ export interface TorrentResult {
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER'; format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
bitrate?: string; bitrate?: string;
hasChapters?: boolean; hasChapters?: boolean;
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
} }
export interface AudiobookRequest { export interface AudiobookRequest {
@@ -27,6 +29,18 @@ export interface AudiobookRequest {
durationMinutes?: number; durationMinutes?: number;
} }
export interface IndexerFlagConfig {
name: string; // Flag name (e.g., "Freeleech")
modifier: number; // -100 to 100 (percentage)
}
export interface BonusModifier {
type: 'indexer_priority' | 'indexer_flag' | 'custom';
value: number; // Multiplier (e.g., 0.4 for 40%)
points: number; // Calculated bonus points from this modifier
reason: string; // Human-readable explanation
}
export interface ScoreBreakdown { export interface ScoreBreakdown {
formatScore: number; formatScore: number;
seederScore: number; seederScore: number;
@@ -37,51 +51,116 @@ export interface ScoreBreakdown {
} }
export interface RankedTorrent extends TorrentResult { export interface RankedTorrent extends TorrentResult {
score: number; score: number; // Base score (0-100)
bonusModifiers: BonusModifier[];
bonusPoints: number; // Sum of all bonus points
finalScore: number; // score + bonusPoints
rank: number; rank: number;
breakdown: ScoreBreakdown; breakdown: ScoreBreakdown;
} }
export class RankingAlgorithm { export class RankingAlgorithm {
/** /**
* Rank all torrents and return sorted by score (best first) * Rank all torrents and return sorted by finalScore (best first)
* @param torrents - Array of torrent results to rank
* @param audiobook - Audiobook request details for matching
* @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10
* @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
*/ */
rankTorrents( rankTorrents(
torrents: TorrentResult[], torrents: TorrentResult[],
audiobook: AudiobookRequest audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): RankedTorrent[] { ): RankedTorrent[] {
const ranked = torrents.map((torrent) => { const ranked = torrents.map((torrent) => {
// Calculate base scores (0-100)
const formatScore = this.scoreFormat(torrent); const formatScore = this.scoreFormat(torrent);
const seederScore = this.scoreSeeders(torrent.seeders); const seederScore = this.scoreSeeders(torrent.seeders);
const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes); const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes);
const matchScore = this.scoreMatch(torrent, audiobook); const matchScore = this.scoreMatch(torrent, audiobook);
const totalScore = formatScore + seederScore + sizeScore + matchScore; const baseScore = formatScore + seederScore + sizeScore + matchScore;
// Calculate bonus modifiers
const bonusModifiers: BonusModifier[] = [];
// Indexer priority bonus (default: 10/25 = 40%)
if (torrent.indexerId !== undefined) {
const priority = indexerPriorities?.get(torrent.indexerId) ?? 10;
const modifier = priority / 25; // Convert 1-25 to 0.04-1.0 (4%-100%)
const points = baseScore * modifier;
bonusModifiers.push({
type: 'indexer_priority',
value: modifier,
points: points,
reason: `Indexer priority ${priority}/25 (${Math.round(modifier * 100)}%)`,
});
}
// Flag bonuses/penalties
if (torrent.flags && torrent.flags.length > 0 && flagConfigs && flagConfigs.length > 0) {
torrent.flags.forEach(torrentFlag => {
// Case-insensitive, whitespace-trimmed matching
const matchingConfig = flagConfigs.find(cfg =>
cfg.name.trim().toLowerCase() === torrentFlag.trim().toLowerCase()
);
if (matchingConfig) {
const modifier = matchingConfig.modifier / 100; // Convert -100 to 100 → -1.0 to 1.0
const points = baseScore * modifier;
bonusModifiers.push({
type: 'indexer_flag',
value: modifier,
points: points,
reason: `Flag "${torrentFlag}" (${matchingConfig.modifier > 0 ? '+' : ''}${matchingConfig.modifier}%)`,
});
}
});
}
// Sum all bonus points
const bonusPoints = bonusModifiers.reduce((sum, mod) => sum + mod.points, 0);
// Calculate final score
const finalScore = baseScore + bonusPoints;
return { return {
...torrent, ...torrent,
score: totalScore, score: baseScore,
bonusModifiers,
bonusPoints,
finalScore,
rank: 0, // Will be assigned after sorting rank: 0, // Will be assigned after sorting
breakdown: { breakdown: {
formatScore, formatScore,
seederScore, seederScore,
sizeScore, sizeScore,
matchScore, matchScore,
totalScore, totalScore: baseScore,
notes: this.generateNotes(torrent, { notes: this.generateNotes(torrent, {
formatScore, formatScore,
seederScore, seederScore,
sizeScore, sizeScore,
matchScore, matchScore,
totalScore, totalScore: baseScore,
notes: [], notes: [],
}), }),
}, },
}; };
}); });
// Sort by score descending (best first) // Sort by finalScore descending (best first), then by publishDate descending (newest first) for tiebreakers
ranked.sort((a, b) => b.score - a.score); ranked.sort((a, b) => {
// Primary: sort by final score
if (b.finalScore !== a.finalScore) {
return b.finalScore - a.finalScore;
}
// Tiebreaker: sort by publishDate (newest first)
return b.publishDate.getTime() - a.publishDate.getTime();
});
// Assign ranks // Assign ranks
ranked.forEach((r, index) => { ranked.forEach((r, index) => {
@@ -210,18 +289,40 @@ export class RankingAlgorithm {
.filter(word => word.length > 0 && !stopList.includes(word)); .filter(word => word.length > 0 && !stopList.includes(word));
}; };
const requestWords = extractWords(requestTitle, stopWords); // Separate required words (outside parentheses/brackets) from optional words (inside)
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
// Extract content in parentheses/brackets as optional
const optionalPattern = /[(\[{]([^)\]}]+)[)\]}]/g;
const optionalMatches: string[] = [];
let match;
while ((match = optionalPattern.exec(title)) !== null) {
optionalMatches.push(match[1]);
}
// Remove parenthetical/bracketed content to get required portion
const required = title.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
const optional = optionalMatches.join(' ');
return { required, optional };
};
const { required: requiredTitle, optional: optionalTitle } = separateRequiredOptional(requestTitle);
// Extract words from required portion only for coverage check
const requiredWords = extractWords(requiredTitle, stopWords);
const torrentWords = extractWords(torrentTitle, stopWords); const torrentWords = extractWords(torrentTitle, stopWords);
// Calculate word coverage: how many REQUEST words appear in TORRENT // Calculate word coverage: how many REQUIRED words appear in TORRENT
if (requestWords.length === 0) { if (requiredWords.length === 0) {
// Edge case: title is only stop words, skip filter // Edge case: title is only stop words or only optional content, skip filter
// Fall through to normal scoring // Fall through to normal scoring
} else { } else {
const matchedWords = requestWords.filter(word => torrentWords.includes(word)); const matchedWords = requiredWords.filter(word => torrentWords.includes(word));
const coverage = matchedWords.length / requestWords.length; const coverage = matchedWords.length / requiredWords.length;
// HARD REQUIREMENT: Must have 80%+ word coverage // HARD REQUIREMENT: Must have 80%+ coverage of REQUIRED words
if (coverage < 0.80) { if (coverage < 0.80) {
// Automatic rejection - doesn't contain enough of the requested words // Automatic rejection - doesn't contain enough of the requested words
return 0; return 0;
@@ -233,19 +334,27 @@ export class RankingAlgorithm {
if (torrentTitle.includes(requestTitle)) { if (torrentTitle.includes(requestTitle)) {
// Found the title, but is it the complete title or part of a longer one? // Found the title, but is it the complete title or part of a longer one?
const titleIndex = torrentTitle.indexOf(requestTitle); const titleIndex = torrentTitle.indexOf(requestTitle);
const beforeTitle = torrentTitle.substring(0, titleIndex);
const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length); const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length);
// Title is complete if followed by clear metadata markers // Extract significant words BEFORE the matched title
// (not followed by more title words like "'s Secret" or " Is Watching") const beforeWords = extractWords(beforeTitle, stopWords);
// Title is complete if:
// 1. No significant words before it (not "This Inevitable Ruin" + "Dungeon Crawler Carl")
// 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching")
const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ',']; const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ','];
const isCompleteTitle = afterTitle === '' || const hasNoWordsPrefix = beforeWords.length === 0;
const hasMetadataSuffix = afterTitle === '' ||
metadataMarkers.some(marker => afterTitle.startsWith(marker)); metadataMarkers.some(marker => afterTitle.startsWith(marker));
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
if (isCompleteTitle) { if (isCompleteTitle) {
// Complete title match → full points // Complete title match → full points
titleScore = 35; titleScore = 35;
} else { } else {
// Title continues with more words (e.g., "The Housemaid" + "'s Secret") // Title has prefix words OR continues with more words
// This is likely a different book in a series → use fuzzy similarity // This is likely a different book in a series → use fuzzy similarity
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35; titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
} }
@@ -373,10 +482,12 @@ export function getRankingAlgorithm(): RankingAlgorithm {
*/ */
export function rankTorrents( export function rankTorrents(
torrents: TorrentResult[], torrents: TorrentResult[],
audiobook: AudiobookRequest audiobook: AudiobookRequest,
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): (RankedTorrent & { qualityScore: number })[] { ): (RankedTorrent & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm(); const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook); const ranked = algorithm.rankTorrents(torrents, audiobook, indexerPriorities, flagConfigs);
// Add qualityScore field for UI compatibility (rounded score) // Add qualityScore field for UI compatibility (rounded score)
return ranked.map((r) => ({ return ranked.map((r) => ({