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
+2 -2
View File
@@ -308,7 +308,7 @@ export function useSearchTorrents() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchTorrents = async (title: string, author: string) => {
const searchTorrents = async (title: string, author: string, asin?: string) => {
if (!accessToken) {
throw new Error('Not authenticated');
}
@@ -322,7 +322,7 @@ export function useSearchTorrents() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, author }),
body: JSON.stringify({ title, author, asin }),
});
const data = await response.json();
+30
View File
@@ -805,6 +805,36 @@ export class AudibleService {
return totalMinutes > 0 ? totalMinutes : undefined;
}
/**
* Get runtime (in minutes) for an audiobook by ASIN
* Lightweight method for size validation during search
* Returns null if not found or error
*/
async getRuntime(asin: string): Promise<number | null> {
try {
// Use Audnexus API for fast, reliable runtime data
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
params: { region: audnexusRegion },
timeout: 5000, // Quick timeout for search performance
headers: { 'User-Agent': 'ReadMeABook/1.0' },
});
const runtimeMin = response.data?.runtimeLengthMin;
if (runtimeMin) {
return parseInt(runtimeMin);
}
return null;
} catch (error: any) {
if (error.response?.status !== 404) {
logger.debug(`Runtime fetch failed for ASIN ${asin}: ${error.message}`);
}
return null;
}
}
/**
* Add delay between requests to respect rate limits
*/
+20 -2
View File
@@ -12,12 +12,18 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Prowlarr');
export interface SearchFilters {
category?: number;
category?: number; // Deprecated: use categories instead
categories?: number[]; // Array of category IDs to search
minSeeders?: number;
maxResults?: number;
indexerIds?: number[];
}
export interface IndexerCategory {
id: number;
name: string;
}
export interface Indexer {
id: number;
name: string;
@@ -26,6 +32,7 @@ export interface Indexer {
priority: number;
capabilities?: {
supportsRss?: boolean;
categories?: IndexerCategory[];
};
fields?: Array<{
name: string;
@@ -119,12 +126,23 @@ export class ProwlarrService {
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
// Determine which categories to search
// Priority: filters.categories > filters.category > defaultCategory
let categoriesToSearch: number[];
if (filters?.categories && filters.categories.length > 0) {
categoriesToSearch = filters.categories;
} else if (filters?.category) {
categoriesToSearch = [filters.category];
} else {
categoriesToSearch = [this.defaultCategory];
}
const params: Record<string, any> = {
query,
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)
categories: categoriesToSearch, // Will be serialized as categories=3030&categories=3040 etc
};
// Filter by specific indexers if provided
@@ -100,6 +100,7 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
matched++;
logger.info(`Triggered search job for request ${request.id}`);
@@ -133,22 +133,22 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
}
}
// Check for downloaded requests to match
const downloadedRequests = await prisma.request.findMany({
// Check for all non-terminal requests to match
const matchableRequests = await prisma.request.findMany({
where: {
status: 'downloaded',
status: { notIn: ['available', 'cancelled'] },
deletedAt: null,
},
include: { audiobook: true },
take: 50,
take: 100,
});
if (downloadedRequests.length > 0) {
logger.info(`Checking ${downloadedRequests.length} downloaded requests for matches`);
if (matchableRequests.length > 0) {
logger.info(`Checking ${matchableRequests.length} matchable requests for matches (all non-terminal statuses)`);
const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) {
for (const request of matchableRequests) {
try {
const audiobook = request.audiobook;
const match = await findPlexMatch({
@@ -159,7 +159,11 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
});
if (match) {
logger.info(`Match found: "${audiobook.title}" → "${match.title}"`);
const originalStatus = request.status;
logger.info(
`Match found: "${audiobook.title}" → "${match.title}"` +
(originalStatus !== 'downloaded' ? ` (was '${originalStatus}')` : '')
);
// Update audiobook with matched library item ID
const updateData: any = { updatedAt: new Date() };
@@ -177,7 +181,15 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
await prisma.request.update({
where: { id: request.id },
data: { status: 'available', completedAt: new Date(), updatedAt: new Date() },
data: {
status: 'available',
completedAt: new Date(),
errorMessage: null,
searchAttempts: 0,
downloadAttempts: 0,
importAttempts: 0,
updatedAt: new Date(),
},
});
matchedDownloads++;
@@ -198,7 +210,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
}
}
logger.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched downloads`);
logger.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched requests`);
return {
success: true,
@@ -53,6 +53,7 @@ export async function processRetryMissingTorrents(payload: RetryMissingTorrentsP
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
asin: request.audiobook.audibleAsin || undefined,
});
triggered++;
logger.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
+18 -10
View File
@@ -316,23 +316,23 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
logger.info(`No orphaned audiobooks found`);
}
// 6. Match downloaded requests against library
logger.info(`Checking for downloaded requests to match...`);
const downloadedRequests = await prisma.request.findMany({
// 6. Match all non-terminal requests against library
logger.info(`Checking for matchable requests...`);
const matchableRequests = await prisma.request.findMany({
where: {
status: 'downloaded',
status: { notIn: ['available', 'cancelled'] },
deletedAt: null,
},
include: { audiobook: true },
take: 50, // Limit to prevent overwhelming
take: 100, // Increased from 50 to handle more eligible requests
});
logger.info(`Found ${downloadedRequests.length} downloaded requests to match`);
logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`);
let matchedCount = 0;
const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) {
for (const request of matchableRequests) {
try {
const audiobook = request.audiobook;
@@ -346,7 +346,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
});
if (match) {
logger.info(`Match found! "${audiobook.title}" -> "${match.title}"`);
const originalStatus = request.status;
logger.info(
`Match found! "${audiobook.title}" -> "${match.title}"` +
(originalStatus !== 'downloaded' ? ` (was '${originalStatus}')` : '')
);
// Update audiobook with matched library item ID (plexGuid or abs_item_id)
const updateData: any = { updatedAt: new Date() };
@@ -362,12 +366,16 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
data: updateData,
});
// Update request to available
// Update request to available and clear any error state
await prisma.request.update({
where: { id: request.id },
data: {
status: 'available',
completedAt: new Date(),
errorMessage: null, // Clear any error state
searchAttempts: 0, // Reset retry counters
downloadAttempts: 0,
importAttempts: 0,
updatedAt: new Date(),
},
});
@@ -389,7 +397,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
}
}
logger.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
logger.info(`Matched ${matchedCount}/${matchableRequests.length} requests`, {
totalScanned: libraryItems.length,
newCount,
updatedCount,
+71 -12
View File
@@ -7,6 +7,7 @@ import { SearchIndexersPayload, getJobQueueService } from '../services/job-queue
import { prisma } from '../db';
import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { RMABLogger } from '../utils/logger';
/**
@@ -41,9 +42,8 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
}
const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) {
if (indexersConfig.length === 0) {
throw new Error('No indexers enabled. Please enable at least one indexer in settings.');
}
@@ -56,7 +56,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`);
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
logger.info(`Group ${index + 1}: ${getGroupDescription(group)}`);
});
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -66,15 +75,31 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching for: "${searchQuery}"`);
// Search indexers - ONLY enabled ones
const searchResults = await prowlarr.search(searchQuery, {
category: 3030, // Audiobooks
minSeeders: 1, // Only torrents with at least 1 seeder
maxResults: 100, // Increased limit for broader search
indexerIds: enabledIndexerIds, // Filter by enabled indexers
});
// Search Prowlarr for each group and combine results
const allResults = [];
logger.info(`Found ${searchResults.length} raw results`);
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.search(searchQuery, {
categories: group.categories,
indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder
maxResults: 100, // Limit per group
});
logger.info(`Group ${i + 1} returned ${groupResults.length} results`);
allResults.push(...groupResults);
} catch (error) {
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other groups even if one fails
}
}
const searchResults = allResults;
logger.info(`Found ${searchResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing
@@ -97,15 +122,45 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
};
}
// Fetch runtime from Audnexus if ASIN available (for size-based scoring/filtering)
let durationMinutes: number | undefined;
if (audiobook.asin) {
const { getAudibleService } = await import('../integrations/audible.service');
const audibleService = getAudibleService();
const runtime = await audibleService.getRuntime(audiobook.asin);
if (runtime) {
durationMinutes = runtime;
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${audiobook.asin}`);
} else {
logger.debug(`No runtime found for ASIN ${audiobook.asin}`);
}
}
// Log filter info
const sizeMBThreshold = 20;
const preFilterCount = searchResults.length;
const belowThreshold = searchResults.filter(r => (r.size / (1024 * 1024)) < sizeMBThreshold);
if (belowThreshold.length > 0) {
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
}
// Get ranking algorithm
const ranker = getRankingAlgorithm();
// Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
durationMinutes,
}, indexerPriorities, flagConfigs);
// Log filter results
const postFilterCount = rankedResults.length;
if (postFilterCount < preFilterCount) {
logger.info(`Filtered out ${preFilterCount - postFilterCount} results < ${sizeMBThreshold} MB`);
}
// Dual threshold filtering:
// 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
@@ -155,12 +210,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`--------------------------------------------------------`);
for (let i = 0; i < top3.length; i++) {
const result = top3[i];
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
logger.info(`${i + 1}. "${result.title}"`);
logger.info(` Indexer: ${result.indexer}${result.indexerId ? ` (ID: ${result.indexerId})` : ''}`);
logger.info(``);
logger.info(` Base Score: ${result.score.toFixed(1)}/100`);
logger.info(` - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/60`);
logger.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
logger.info(` - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`);
logger.info(` - Size Quality: ${durationMinutes ? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min, ${durationMinutes} min runtime)` : 'N/A (no runtime data)'}`);
logger.info(` - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`);
logger.info(``);
logger.info(` Bonus Points: +${result.bonusPoints.toFixed(1)}`);
+2 -1
View File
@@ -37,6 +37,7 @@ export interface SearchIndexersPayload extends JobPayload {
id: string;
title: string;
author: string;
asin?: string; // Optional ASIN for runtime-based size scoring
};
}
@@ -441,7 +442,7 @@ export class JobQueueService {
/**
* Add search indexers job
*/
async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string }): Promise<string> {
async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }): Promise<string> {
return await this.addJob(
'search_indexers',
{
+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;
}