Refactor indexer management and improve search logic

Refactors admin settings to use a new IndexersTab and card-based indexer management UI, supporting category selection and improved configuration. Updates backend and API routes to handle indexer categories, propagate ASIN for better search scoring, and group indexers by categories to optimize Prowlarr searches. Enhances documentation to clarify non-terminal request matching and auto-completion behavior. Adds new reusable components for indexer management and category selection.
This commit is contained in:
kikootwo
2026-01-13 21:32:54 -05:00
parent e346f88f42
commit 307b63fab4
30 changed files with 1787 additions and 671 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* Utility: Indexer Grouping by Categories
* Documentation: documentation/phase3/prowlarr.md
*
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
*/
export interface IndexerConfig {
id: number;
name: string;
priority?: number;
categories?: number[];
[key: string]: any; // Allow other properties
}
export interface IndexerGroup {
categories: number[];
indexerIds: number[];
indexers: IndexerConfig[];
}
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
*
* @param indexers - Array of indexer configurations
* @returns Array of groups, each containing indexers with matching categories
*
* @example
* const indexers = [
* { id: 1, categories: [3030] },
* { id: 2, categories: [3030] },
* { id: 3, categories: [3030, 3010] },
* ];
*
* const groups = groupIndexersByCategories(indexers);
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*/
export function groupIndexersByCategories(indexers: IndexerConfig[]): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
const groupMap = new Map<string, IndexerConfig[]>();
for (const indexer of indexers) {
// Get categories, default to [3030] (audiobooks) if not specified
const categories = indexer.categories && indexer.categories.length > 0
? indexer.categories
: [3030];
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
const sortedCategories = [...categories].sort((a, b) => a - b);
const key = sortedCategories.join(',');
// Add indexer to group
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(indexer);
}
// Convert map to array of groups
const groups: IndexerGroup[] = [];
for (const [key, indexersInGroup] of groupMap.entries()) {
const categories = key.split(',').map(Number);
const indexerIds = indexersInGroup.map(idx => idx.id);
groups.push({
categories,
indexerIds,
indexers: indexersInGroup,
});
}
return groups;
}
/**
* Get a human-readable description of an indexer group.
* Useful for logging and debugging.
*
* @param group - The indexer group
* @returns Description string
*
* @example
* const description = getGroupDescription(group);
* // "3 indexers (IDs: 1, 2, 5) searching categories [3030, 3010]"
*/
export function getGroupDescription(group: IndexerGroup): string {
const indexerCount = group.indexerIds.length;
const indexerNames = group.indexers.map(idx => idx.name).join(', ');
const categoriesStr = group.categories.join(', ');
return `${indexerCount} indexer${indexerCount > 1 ? 's' : ''} (${indexerNames}) with categories [${categoriesStr}]`;
}
+87 -17
View File
@@ -45,6 +45,7 @@ export interface BonusModifier {
export interface ScoreBreakdown {
formatScore: number;
sizeScore: number;
seederScore: number;
matchScore: number;
totalScore: number;
@@ -64,7 +65,7 @@ export class RankingAlgorithm {
/**
* 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 audiobook - Audiobook request details for matching (includes durationMinutes for size scoring)
* @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10
* @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
*/
@@ -74,13 +75,20 @@ export class RankingAlgorithm {
indexerPriorities?: Map<number, number>,
flagConfigs?: IndexerFlagConfig[]
): RankedTorrent[] {
const ranked = torrents.map((torrent) => {
// Filter out files < 20 MB (likely ebooks/samples)
const filteredTorrents = torrents.filter((torrent) => {
const sizeMB = torrent.size / (1024 * 1024);
return sizeMB >= 20;
});
const ranked = filteredTorrents.map((torrent) => {
// Calculate base scores (0-100)
const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
const baseScore = formatScore + seederScore + matchScore;
const baseScore = formatScore + sizeScore + seederScore + matchScore;
// Calculate bonus modifiers
const bonusModifiers: BonusModifier[] = [];
@@ -136,16 +144,18 @@ export class RankingAlgorithm {
rank: 0, // Will be assigned after sorting
breakdown: {
formatScore,
sizeScore,
seederScore,
matchScore,
totalScore: baseScore,
notes: this.generateNotes(torrent, {
formatScore,
sizeScore,
seederScore,
matchScore,
totalScore: baseScore,
notes: [],
}),
}, audiobook.durationMinutes),
},
};
});
@@ -176,48 +186,89 @@ export class RankingAlgorithm {
audiobook: AudiobookRequest
): ScoreBreakdown {
const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook);
const totalScore = formatScore + seederScore + matchScore;
const totalScore = formatScore + sizeScore + seederScore + matchScore;
return {
formatScore,
sizeScore,
seederScore,
matchScore,
totalScore,
notes: this.generateNotes(torrent, {
formatScore,
sizeScore,
seederScore,
matchScore,
totalScore,
notes: [],
}),
}, audiobook.durationMinutes),
};
}
/**
* Score format quality (25 points max)
* M4B with chapters: 25 pts
* M4B without chapters: 22 pts
* M4A: 16 pts
* MP3: 10 pts
* Other: 3 pts
* Score format quality (10 points max)
* Reduced from 25 to make room for data-driven size scoring
* M4B with chapters: 10 pts
* M4B without chapters: 9 pts
* M4A: 6 pts
* MP3: 4 pts
* Other: 1 pt
*/
private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent);
switch (format) {
case 'M4B':
return torrent.hasChapters !== false ? 25 : 22;
return torrent.hasChapters !== false ? 10 : 9;
case 'M4A':
return 16;
return 6;
case 'MP3':
return 10;
return 4;
default:
return 3;
return 1;
}
}
/**
* Score file size quality (15 points max)
* Uses book runtime and file size to validate correct file type
* Filters out ebooks and ranks audiobook quality
*
* @param torrent - Torrent result with size in bytes
* @param runtimeMinutes - Book runtime in minutes from Audnexus
* @returns 0-15 points based on MB/min ratio
*
* Algorithm:
* - >= 1.0 MB/min → 15/15 points (high quality baseline)
* - Linear scaling below 1.0 MB/min
* - 0 points if no runtime data (graceful degradation)
*
* Note: Files < 20 MB are pre-filtered in rankTorrents()
*/
private scoreSize(torrent: TorrentResult, runtimeMinutes: number | undefined): number {
// Graceful degradation: no runtime data = no size scoring
if (!runtimeMinutes || runtimeMinutes === 0) {
return 0;
}
const sizeMB = torrent.size / (1024 * 1024);
const mbPerMin = sizeMB / runtimeMinutes;
// High quality baseline: 1.0 MB/min or higher gets full points
// This is ~64 kbps MP3 equivalent
if (mbPerMin >= 1.0) {
return 15;
}
// Linear scaling below baseline
// 0.5 MB/min = 7.5 points
// 0.3 MB/min = 4.5 points
return mbPerMin * 15;
}
/**
* Score seeder count (15 points max)
* Logarithmic scaling:
@@ -429,7 +480,8 @@ export class RankingAlgorithm {
*/
private generateNotes(
torrent: TorrentResult,
breakdown: ScoreBreakdown
breakdown: ScoreBreakdown,
runtimeMinutes?: number
): string[] {
const notes: string[] = [];
@@ -448,6 +500,24 @@ export class RankingAlgorithm {
notes.push('Unknown or uncommon format');
}
// Size notes
if (runtimeMinutes && runtimeMinutes > 0) {
const sizeMB = torrent.size / (1024 * 1024);
const mbPerMin = sizeMB / runtimeMinutes;
if (mbPerMin >= 1.5) {
notes.push('✓ Premium quality (high bitrate)');
} else if (mbPerMin >= 1.0) {
notes.push('✓ High quality');
} else if (mbPerMin >= 0.5) {
notes.push('Standard quality');
} else if (mbPerMin >= 0.3) {
notes.push('⚠️ Low quality (low bitrate)');
} else {
notes.push('⚠️ Very low quality - may be ebook');
}
}
// Seeder notes (skip for NZB/Usenet results which don't have seeders)
if (torrent.seeders !== undefined && torrent.seeders !== null && !isNaN(torrent.seeders)) {
if (torrent.seeders === 0) {
+78
View File
@@ -0,0 +1,78 @@
/**
* Predefined Torrent Category Tree
* Documentation: documentation/phase3/prowlarr.md
*/
export interface TorrentCategory {
id: number;
name: string;
children?: TorrentCategory[];
}
export const TORRENT_CATEGORIES: TorrentCategory[] = [
{
id: 3000,
name: 'Audio',
children: [
{ id: 3010, name: 'MP3' },
{ id: 3030, name: 'Audiobook' },
{ id: 3040, name: 'Lossless' },
{ id: 3050, name: 'Other' },
{ id: 3060, name: 'Foreign' },
],
},
{
id: 7000,
name: 'Books',
children: [
{ id: 7020, name: 'EBook' },
{ id: 7050, name: 'Other' },
{ id: 7060, name: 'Foreign' },
],
},
{
id: 8000,
name: 'Other',
},
];
export const DEFAULT_CATEGORIES = [3030]; // Audio/Audiobook
/**
* Get all child IDs for a parent category
*/
export function getChildIds(parentId: number): number[] {
const parent = TORRENT_CATEGORIES.find((cat) => cat.id === parentId);
return parent?.children?.map((child) => child.id) || [];
}
/**
* Get parent ID for a child category
*/
export function getParentId(childId: number): number | null {
for (const parent of TORRENT_CATEGORIES) {
if (parent.children?.some((child) => child.id === childId)) {
return parent.id;
}
}
return null;
}
/**
* Check if all children of a parent are selected
*/
export function areAllChildrenSelected(
parentId: number,
selectedIds: number[]
): boolean {
const childIds = getChildIds(parentId);
return childIds.length > 0 && childIds.every((id) => selectedIds.includes(id));
}
/**
* Check if a category is a parent (has children)
*/
export function isParentCategory(categoryId: number): boolean {
const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId);
return !!category?.children && category.children.length > 0;
}