mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
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:
@@ -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}]`;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user