Implement chapter merging feature and update ranking algorithm

Added automatic chapter merging to M4B with admin/config toggles, UI controls, and backend logic. Updated documentation to reflect implementation. Refactored ranking algorithm: increased Title/Author match points, removed size scoring, and improved Usenet/torrent handling. Enhanced Prowlarr integration for protocol detection and filtering. Improved file organizer to support chapter merging. Various bug fixes and logging improvements.
This commit is contained in:
kikootwo
2026-01-08 16:26:26 -05:00
parent 722a78ac33
commit 288421012d
21 changed files with 922 additions and 128 deletions
+81 -13
View File
@@ -46,8 +46,8 @@ interface ProwlarrSearchResult {
indexerId?: number;
title: string;
size: number;
seeders: number;
leechers: number;
seeders?: number; // Optional for NZB/Usenet results
leechers?: number; // Optional for NZB/Usenet results
publishDate: string;
downloadUrl?: string; // Torrent file download URL (most indexers)
magnetUrl?: string; // Magnet link (public trackers like TPB)
@@ -57,6 +57,7 @@ interface ProwlarrSearchResult {
downloadVolumeFactor?: number;
uploadVolumeFactor?: number;
indexerFlags?: string[] | number[]; // Can be string names or numeric IDs
protocol?: string; // 'torrent' or 'usenet' - provided by Prowlarr API
[key: string]: any; // Allow any additional fields from Prowlarr API
}
@@ -77,9 +78,28 @@ export class ProwlarrService {
},
timeout: 30000, // 30 seconds
paramsSerializer: {
indexes: null, // Use repeat format: indexerIds=1&indexerIds=2 (not indexerIds[0]=1)
serialize: (params) => {
// Custom serializer to handle arrays correctly for Prowlarr API
// indexerIds=[1,2,3] should become indexerIds=1&indexerIds=2&indexerIds=3
const parts: string[] = [];
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach(v => parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`));
} else if (value !== undefined && value !== null) {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return parts.join('&');
},
},
});
// Debug interceptor to log actual outgoing requests
this.client.interceptors.request.use((config) => {
console.log(`[Prowlarr] Actual request: ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`);
console.log(`[Prowlarr] Request params:`, JSON.stringify(config.params));
return config;
});
}
/**
@@ -91,25 +111,44 @@ export class ProwlarrService {
filters?: SearchFilters
): Promise<TorrentResult[]> {
try {
// Get configured download client type to determine if we should filter by category
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
const params: Record<string, any> = {
query,
categories: filters?.category?.toString() || this.defaultCategory.toString(),
type: 'search',
limit: 100, // Maximum results to return from Prowlarr
extended: 1, // Enable searching in tags, labels, and metadata
categories: filters?.category?.toString() || this.defaultCategory.toString(), // 3030 = Audiobooks (standard Newznab category)
};
// Filter by specific indexers if provided
// Pass array directly - axios will serialize as indexerIds=1&indexerIds=2&indexerIds=3
if (filters?.indexerIds && filters.indexerIds.length > 0) {
params.indexerIds = filters.indexerIds;
}
const response = await this.client.get('/search', { params });
console.log(`[Prowlarr] Raw API response: ${response.data.length} results`);
// Debug: Log first raw result to see structure (debug mode only)
// Debug: Log first raw result to see structure and protocol field
if (response.data.length > 0) {
const firstResult = response.data[0];
console.log(`[Prowlarr] First raw result - protocol: "${firstResult.protocol}", indexer: "${firstResult.indexer}", title: "${firstResult.title?.substring(0, 50)}..."`);
// Check protocol distribution in raw results
const rawProtocols = response.data.reduce((acc: Record<string, number>, r: any) => {
const proto = r.protocol || 'missing';
acc[proto] = (acc[proto] || 0) + 1;
return acc;
}, {});
console.log(`[Prowlarr] Raw protocol distribution:`, JSON.stringify(rawProtocols));
}
// Debug: Log first raw result full structure (debug mode only)
if (process.env.LOG_LEVEL === 'debug' && response.data.length > 0) {
console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2));
console.log(`[Prowlarr] Received ${response.data.length} total results from API`);
}
// Transform Prowlarr results to our format
@@ -130,7 +169,12 @@ export class ProwlarrService {
// Apply additional filters
if (filters?.minSeeders) {
filtered = filtered.filter((r) => r.seeders >= (filters.minSeeders || 0));
// Only apply seeder filter to torrent results (NZB results don't have seeders)
filtered = filtered.filter((r) => {
// Skip filter for NZB results (undefined seeders)
if (r.seeders === undefined) return true;
return r.seeders >= (filters.minSeeders || 0);
});
}
if (filters?.maxResults) {
@@ -318,6 +362,26 @@ export class ProwlarrService {
const config = await getConfigService();
const clientType = (await config.get('download_client_type')) || 'qbittorrent';
// Debug: Log protocol distribution
const protocolCounts = results.reduce((acc, r) => {
const proto = r.protocol || 'unknown';
acc[proto] = (acc[proto] || 0) + 1;
return acc;
}, {} as Record<string, number>);
console.log(`[Prowlarr] Protocol distribution in ${results.length} results:`, JSON.stringify(protocolCounts));
// Debug: Log first few results to see their protocols
if (results.length > 0 && results.length <= 5) {
results.forEach((r, i) => {
console.log(`[Prowlarr] Result ${i + 1}: protocol="${r.protocol || 'undefined'}", url="${r.downloadUrl.substring(0, 80)}..."`);
});
} else if (results.length > 5) {
console.log(`[Prowlarr] First 3 results:`);
results.slice(0, 3).forEach((r, i) => {
console.log(`[Prowlarr] ${i + 1}: protocol="${r.protocol || 'undefined'}", isNZB=${ProwlarrService.isNZBResult(r)}`);
});
}
if (clientType === 'sabnzbd') {
// Filter for NZB results only
const filtered = results.filter(result => ProwlarrService.isNZBResult(result));
@@ -340,6 +404,12 @@ export class ProwlarrService {
* Static method for protocol detection
*/
static isNZBResult(result: TorrentResult): boolean {
// Check protocol field first (most reliable - provided by Prowlarr API)
if (result.protocol) {
return result.protocol.toLowerCase() === 'usenet';
}
// Fallback to URL pattern detection if protocol not provided
const url = result.downloadUrl.toLowerCase();
// Check file extension
@@ -347,14 +417,11 @@ export class ProwlarrService {
return true;
}
// Check URL path
if (url.includes('/nzb/') || url.includes('&t=get')) {
// Check URL path patterns common in Newznab APIs
if (url.includes('/nzb/') || url.includes('&t=get') || url.includes('/getnzb')) {
return true;
}
// Check categories (3030 is audiobooks, but some indexers use Usenet-specific codes)
// Note: This is less reliable, so we prioritize URL patterns
return false;
}
@@ -394,6 +461,7 @@ export class ProwlarrService {
bitrate: metadata.bitrate,
hasChapters: metadata.hasChapters,
flags: flags.length > 0 ? flags : undefined,
protocol: result.protocol, // 'torrent' or 'usenet'
};
} catch (error) {
console.error('Failed to transform result:', result, error);
+2
View File
@@ -267,6 +267,8 @@ export class SABnzbdService {
* Returns the NZB ID
*/
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
console.log(`[SABnzbd] Adding NZB from URL: ${url.substring(0, 150)}...`);
const response = await this.client.get('/api', {
params: {
mode: 'addurl',