mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -174,12 +174,16 @@ export class AudibleService {
|
||||
|
||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
audiobooks.push({
|
||||
asin,
|
||||
title,
|
||||
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
rating,
|
||||
});
|
||||
|
||||
foundOnPage++;
|
||||
@@ -249,6 +253,9 @@ export class AudibleService {
|
||||
const runtimeText = $el.find('.runtimeLabel').text().trim();
|
||||
const durationMinutes = this.parseRuntime(runtimeText);
|
||||
|
||||
const ratingText = $el.find('.ratingsLabel').text().trim();
|
||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||
|
||||
audiobooks.push({
|
||||
asin,
|
||||
title,
|
||||
@@ -256,6 +263,7 @@ export class AudibleService {
|
||||
narrator: narratorText.replace('Narrated by:', '').trim(),
|
||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||
durationMinutes,
|
||||
rating,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface IndexerStats {
|
||||
interface ProwlarrSearchResult {
|
||||
guid: string;
|
||||
indexer: string;
|
||||
indexerId?: number;
|
||||
title: string;
|
||||
size: number;
|
||||
seeders: number;
|
||||
@@ -51,6 +52,10 @@ interface ProwlarrSearchResult {
|
||||
downloadUrl: string;
|
||||
infoHash?: string;
|
||||
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 {
|
||||
@@ -99,6 +104,11 @@ export class ProwlarrService {
|
||||
|
||||
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
|
||||
const results = response.data
|
||||
.map((result: ProwlarrSearchResult) => this.transformResult(result))
|
||||
@@ -232,6 +242,7 @@ export class ProwlarrService {
|
||||
|
||||
const result: TorrentResult = {
|
||||
indexer: item.prowlarrindexer?.['#text'] || item.prowlarrindexer || 'Unknown',
|
||||
indexerId: indexerId,
|
||||
title: item.title || '',
|
||||
size: parseInt(item.size || '0', 10),
|
||||
seeders,
|
||||
@@ -296,8 +307,12 @@ export class ProwlarrService {
|
||||
// Extract metadata from title
|
||||
const metadata = this.extractMetadata(result.title);
|
||||
|
||||
// Extract flags from result
|
||||
const flags = this.extractFlags(result);
|
||||
|
||||
return {
|
||||
indexer: result.indexer,
|
||||
indexerId: result.indexerId,
|
||||
title: result.title,
|
||||
size: result.size,
|
||||
seeders: result.seeders,
|
||||
@@ -309,6 +324,7 @@ export class ProwlarrService {
|
||||
format: metadata.format,
|
||||
bitrate: metadata.bitrate,
|
||||
hasChapters: metadata.hasChapters,
|
||||
flags: flags.length > 0 ? flags : undefined,
|
||||
};
|
||||
} catch (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
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import https from 'https';
|
||||
import * as parseTorrentModule from 'parse-torrent';
|
||||
import FormData from 'form-data';
|
||||
|
||||
@@ -80,23 +81,36 @@ export class QBittorrentService {
|
||||
private cookie?: string;
|
||||
private defaultSavePath: string;
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
private httpsAgent?: https.Agent;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
defaultSavePath: string = '/downloads',
|
||||
defaultCategory: string = 'readmeabook'
|
||||
defaultCategory: string = 'readmeabook',
|
||||
disableSSLVerify: boolean = false
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
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({
|
||||
baseURL: `${this.baseUrl}/api/v2`,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +127,7 @@ export class QBittorrentService {
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
httpsAgent: this.httpsAgent,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -660,35 +675,123 @@ export class QBittorrentService {
|
||||
static async testConnectionWithCredentials(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
disableSSLVerify: boolean = false
|
||||
): Promise<string> {
|
||||
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 {
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v2/auth/login`,
|
||||
new URLSearchParams({ username, password }),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
httpsAgent,
|
||||
}
|
||||
);
|
||||
|
||||
// Get version to confirm connection
|
||||
const cookies = response.headers['set-cookie'];
|
||||
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 versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
|
||||
headers: { Cookie: cookie },
|
||||
httpsAgent,
|
||||
});
|
||||
|
||||
return versionResponse.data || 'Connected';
|
||||
} catch (error) {
|
||||
console.error('qBittorrent connection test failed:', error);
|
||||
throw new Error('Failed to connect to qBittorrent');
|
||||
console.error('[qBittorrent] Connection test failed:', error);
|
||||
|
||||
// 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_password',
|
||||
'download_dir',
|
||||
'download_client_disable_ssl_verify',
|
||||
]);
|
||||
|
||||
console.log('[qBittorrent] Config loaded:', {
|
||||
@@ -779,6 +883,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
hasUsername: !!config.download_client_username,
|
||||
hasPassword: !!config.download_client_password,
|
||||
hasPath: !!config.download_dir,
|
||||
disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
|
||||
});
|
||||
|
||||
// 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 password = config.download_client_password as string;
|
||||
const savePath = config.download_dir as string;
|
||||
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
|
||||
|
||||
console.log('[qBittorrent] Creating service instance...');
|
||||
qbittorrentService = new QBittorrentService(
|
||||
@@ -815,7 +921,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
|
||||
username,
|
||||
password,
|
||||
savePath,
|
||||
'readmeabook'
|
||||
'readmeabook',
|
||||
disableSSLVerify
|
||||
);
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
// 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`);
|
||||
|
||||
// Get Prowlarr service
|
||||
@@ -91,17 +100,28 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
// Get ranking algorithm
|
||||
const ranker = getRankingAlgorithm();
|
||||
|
||||
// Rank results
|
||||
// Rank results with indexer priorities and flag configs
|
||||
const rankedResults = ranker.rankTorrents(searchResults, {
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
durationMinutes: undefined, // We don't have duration from Audible
|
||||
});
|
||||
}, indexerPriorities, flagConfigs);
|
||||
|
||||
// Filter out results below minimum score threshold (50/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 50);
|
||||
// Dual threshold filtering:
|
||||
// 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) {
|
||||
// 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++) {
|
||||
const result = top3[i];
|
||||
await logger?.info(`${i + 1}. "${result.title}"`);
|
||||
await logger?.info(` Indexer: ${result.indexer}`);
|
||||
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(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
|
||||
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) {
|
||||
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(`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
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { compareTwoStrings } from 'string-similarity';
|
||||
|
||||
export interface TorrentResult {
|
||||
indexer: string;
|
||||
indexerId?: number;
|
||||
title: string;
|
||||
size: number;
|
||||
seeders: number;
|
||||
@@ -18,6 +19,7 @@ export interface TorrentResult {
|
||||
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
|
||||
bitrate?: string;
|
||||
hasChapters?: boolean;
|
||||
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
|
||||
}
|
||||
|
||||
export interface AudiobookRequest {
|
||||
@@ -27,6 +29,18 @@ export interface AudiobookRequest {
|
||||
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 {
|
||||
formatScore: number;
|
||||
seederScore: number;
|
||||
@@ -37,51 +51,116 @@ export interface ScoreBreakdown {
|
||||
}
|
||||
|
||||
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;
|
||||
breakdown: ScoreBreakdown;
|
||||
}
|
||||
|
||||
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(
|
||||
torrents: TorrentResult[],
|
||||
audiobook: AudiobookRequest
|
||||
audiobook: AudiobookRequest,
|
||||
indexerPriorities?: Map<number, number>,
|
||||
flagConfigs?: IndexerFlagConfig[]
|
||||
): RankedTorrent[] {
|
||||
const ranked = torrents.map((torrent) => {
|
||||
// Calculate base scores (0-100)
|
||||
const formatScore = this.scoreFormat(torrent);
|
||||
const seederScore = this.scoreSeeders(torrent.seeders);
|
||||
const sizeScore = this.scoreSize(torrent.size, audiobook.durationMinutes);
|
||||
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 {
|
||||
...torrent,
|
||||
score: totalScore,
|
||||
score: baseScore,
|
||||
bonusModifiers,
|
||||
bonusPoints,
|
||||
finalScore,
|
||||
rank: 0, // Will be assigned after sorting
|
||||
breakdown: {
|
||||
formatScore,
|
||||
seederScore,
|
||||
sizeScore,
|
||||
matchScore,
|
||||
totalScore,
|
||||
totalScore: baseScore,
|
||||
notes: this.generateNotes(torrent, {
|
||||
formatScore,
|
||||
seederScore,
|
||||
sizeScore,
|
||||
matchScore,
|
||||
totalScore,
|
||||
totalScore: baseScore,
|
||||
notes: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending (best first)
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
// Sort by finalScore descending (best first), then by publishDate descending (newest first) for tiebreakers
|
||||
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
|
||||
ranked.forEach((r, index) => {
|
||||
@@ -210,18 +289,40 @@ export class RankingAlgorithm {
|
||||
.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);
|
||||
|
||||
// Calculate word coverage: how many REQUEST words appear in TORRENT
|
||||
if (requestWords.length === 0) {
|
||||
// Edge case: title is only stop words, skip filter
|
||||
// Calculate word coverage: how many REQUIRED words appear in TORRENT
|
||||
if (requiredWords.length === 0) {
|
||||
// Edge case: title is only stop words or only optional content, skip filter
|
||||
// Fall through to normal scoring
|
||||
} else {
|
||||
const matchedWords = requestWords.filter(word => torrentWords.includes(word));
|
||||
const coverage = matchedWords.length / requestWords.length;
|
||||
const matchedWords = requiredWords.filter(word => torrentWords.includes(word));
|
||||
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) {
|
||||
// Automatic rejection - doesn't contain enough of the requested words
|
||||
return 0;
|
||||
@@ -233,19 +334,27 @@ export class RankingAlgorithm {
|
||||
if (torrentTitle.includes(requestTitle)) {
|
||||
// Found the title, but is it the complete title or part of a longer one?
|
||||
const titleIndex = torrentTitle.indexOf(requestTitle);
|
||||
const beforeTitle = torrentTitle.substring(0, titleIndex);
|
||||
const afterTitle = torrentTitle.substring(titleIndex + requestTitle.length);
|
||||
|
||||
// Title is complete if followed by clear metadata markers
|
||||
// (not followed by more title words like "'s Secret" or " Is Watching")
|
||||
// Extract significant words BEFORE the matched title
|
||||
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 isCompleteTitle = afterTitle === '' ||
|
||||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
|
||||
const hasNoWordsPrefix = beforeWords.length === 0;
|
||||
const hasMetadataSuffix = afterTitle === '' ||
|
||||
metadataMarkers.some(marker => afterTitle.startsWith(marker));
|
||||
|
||||
const isCompleteTitle = hasNoWordsPrefix && hasMetadataSuffix;
|
||||
|
||||
if (isCompleteTitle) {
|
||||
// Complete title match → full points
|
||||
titleScore = 35;
|
||||
} 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
|
||||
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
|
||||
}
|
||||
@@ -373,10 +482,12 @@ export function getRankingAlgorithm(): RankingAlgorithm {
|
||||
*/
|
||||
export function rankTorrents(
|
||||
torrents: TorrentResult[],
|
||||
audiobook: AudiobookRequest
|
||||
audiobook: AudiobookRequest,
|
||||
indexerPriorities?: Map<number, number>,
|
||||
flagConfigs?: IndexerFlagConfig[]
|
||||
): (RankedTorrent & { qualityScore: number })[] {
|
||||
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)
|
||||
return ranked.map((r) => ({
|
||||
|
||||
Reference in New Issue
Block a user