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
|
||||
|
||||
Reference in New Issue
Block a user