Add per-indexer ratio-based seeding policy

Introduce a per-indexer ratioLimit alongside seedingTimeMinutes to control torrent cleanup. Updates include: documentation (scheduler and settings pages), types and API (saved indexer config now includes ratioLimit), setup and management UI (new TorrentSeedingFields component, modal wiring, validation and handlers), and processor logic (cleanup-seeded-torrents now requires AND-semantics between time and ratio; 0 disables a criterion, both 0 = never cleaned, undefined client ratio with ratioLimit>0 = not met). Tests were added/updated to cover ratio-only, time+ratio, missing-ratio, and UI interactions. Default behavior: ratioLimit defaults to 0 (no ratio requirement).
This commit is contained in:
kikootwo
2026-05-18 15:07:50 -04:00
parent 01e61f3368
commit 411b5f88a4
13 changed files with 407 additions and 41 deletions
+2
View File
@@ -169,6 +169,7 @@ export interface IndexerConfig {
enabled: boolean;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -185,6 +186,7 @@ export interface SavedIndexerConfig {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean;
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
@@ -79,6 +80,7 @@ export async function GET(request: NextRequest) {
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
config.ratioLimit = saved?.ratioLimit ?? 0;
} else {
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
}
@@ -134,6 +136,7 @@ export async function PUT(request: NextRequest) {
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
if (isTorrent) {
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
config.ratioLimit = indexer.ratioLimit ?? 0;
} else {
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
}
+1
View File
@@ -25,6 +25,7 @@ interface SelectedIndexer {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches
@@ -1,9 +1,6 @@
/**
* Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md
*
* Supports separate category configurations for AudioBook and EBook searches
* via tabbed interface in the Categories section.
*/
'use client';
@@ -13,6 +10,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView';
import { TorrentSeedingFields } from './IndexerConfigModalTorrentFields';
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
type CategoryTab = 'audiobook' | 'ebook';
@@ -30,6 +28,7 @@ interface IndexerConfigModalProps {
initialConfig?: {
priority: number;
seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
@@ -41,6 +40,7 @@ interface IndexerConfigModalProps {
protocol: string;
priority: number;
seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
@@ -61,6 +61,7 @@ export function IndexerConfigModal({
const defaults = {
priority: 10,
seedingTimeMinutes: 0,
ratioLimit: 0,
removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss,
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
@@ -74,6 +75,9 @@ export function IndexerConfigModal({
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
);
const [ratioLimit, setRatioLimit] = useState(
initialConfig?.ratioLimit ?? defaults.ratioLimit
);
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
);
@@ -89,21 +93,20 @@ export function IndexerConfigModal({
initialConfig?.ebookCategories ?? defaults.ebookCategories
);
// Tab state for categories
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
// Validation errors
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
ratioLimit?: string;
}>({});
// Reset form when modal opens or indexer changes
useEffect(() => {
if (isOpen) {
if (mode === 'add') {
setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRatioLimit(defaults.ratioLimit);
setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled);
setAudiobookCategories(defaults.audiobookCategories);
@@ -111,6 +114,7 @@ export function IndexerConfigModal({
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRatioLimit(initialConfig?.ratioLimit ?? defaults.ratioLimit);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
@@ -132,6 +136,10 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (isTorrent && ratioLimit < 0) {
newErrors.ratioLimit = 'Ratio limit cannot be negative';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -154,6 +162,7 @@ export function IndexerConfigModal({
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = seedingTimeMinutes;
config.ratioLimit = ratioLimit;
} else {
config.removeAfterProcessing = removeAfterProcessing;
}
@@ -183,6 +192,17 @@ export function IndexerConfigModal({
}
};
const handleRatioLimitChange = (value: string) => {
if (value === '') {
setRatioLimit(0);
} else {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
setRatioLimit(Math.max(0, parsed));
}
}
};
// Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
@@ -238,30 +258,15 @@ export function IndexerConfigModal({
)}
</div>
{/* Seeding Time (Torrents only) */}
{/* Seeding Time + Ratio Limit (Torrents only) */}
{isTorrent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
<TorrentSeedingFields
seedingTimeMinutes={seedingTimeMinutes}
ratioLimit={ratioLimit}
errors={errors}
onSeedingTimeChange={handleSeedingTimeChange}
onRatioLimitChange={handleRatioLimitChange}
/>
)}
{/* Remove After Processing (Usenet only) */}
@@ -0,0 +1,78 @@
/**
* Component: Indexer Config Modal — Torrent Seeding Fields
* Documentation: documentation/frontend/components.md
*
* Renders the torrent-only "Seeding Time" + "Ratio Limit" inputs used by
* IndexerConfigModal. Extracted to keep the parent modal under 400 lines.
*/
'use client';
import React from 'react';
import { Input } from '@/components/ui/Input';
export interface TorrentSeedingFieldsProps {
seedingTimeMinutes: number;
ratioLimit: number;
errors: { seedingTimeMinutes?: string; ratioLimit?: string };
onSeedingTimeChange: (value: string) => void;
onRatioLimitChange: (value: string) => void;
}
export function TorrentSeedingFields({
seedingTimeMinutes,
ratioLimit,
errors,
onSeedingTimeChange,
onRatioLimitChange,
}: TorrentSeedingFieldsProps) {
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => onSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Ratio Limit
</label>
<Input
type="number"
min="0"
step="0.01"
value={ratioLimit}
onChange={(e) => onRatioLimitChange(e.target.value)}
placeholder="0"
className={errors.ratioLimit ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Minimum upload/download ratio before files are cleaned up. 0 = no ratio requirement.
</p>
{errors.ratioLimit && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.ratioLimit}
</p>
)}
</div>
</>
);
}
@@ -26,6 +26,7 @@ interface SavedIndexerConfig {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches
@@ -133,8 +133,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
// Find matching indexer configuration by name
const seedingConfig = indexerConfigMap.get(indexerName);
// If no config found or seeding time is 0 (unlimited)
if (!seedingConfig || seedingConfig.seedingTimeMinutes === 0) {
// Per-indexer thresholds. 0 disables that criterion; both 0 = unlimited.
const seedingMin: number = seedingConfig?.seedingTimeMinutes ?? 0;
const ratioMin: number = seedingConfig?.ratioLimit ?? 0;
// If no config found or both criteria are 0 (unlimited)
if (!seedingConfig || (seedingMin === 0 && ratioMin === 0)) {
// For soft-deleted requests with unlimited seeding, hard delete immediately
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
@@ -144,7 +148,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const seedingTimeSeconds = seedingMin * 60;
// Skip if this torrent was already deleted earlier in this run
if (deletedHashes.has(clientId.toLowerCase())) {
@@ -178,17 +182,30 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
// Check if seeding time requirement is met
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
// Check seeding requirements: AND-semantics across time and ratio.
// Each criterion is "met" when disabled (0) or when actual meets/exceeds it.
// Undefined ratio with ratioMin > 0 is treated as not-met (safe-deny).
const actualSeedingTime = downloadInfo.seedingTime ?? 0;
const actualRatio = downloadInfo.ratio;
const timeMet = seedingMin === 0 || actualSeedingTime >= seedingTimeSeconds;
const ratioMet = ratioMin === 0 || (typeof actualRatio === 'number' && actualRatio >= ratioMin);
const hasMetRequirement = timeMet && ratioMet;
const ratioPart = ratioMin === 0
? '--/--'
: `${(typeof actualRatio === 'number' ? actualRatio : 0).toFixed(2)}/${ratioMin.toFixed(2)}`;
const timePart = seedingMin === 0
? '--/--'
: `${Math.floor(actualSeedingTime / 60)}/${seedingMin}`;
const progress = `ratio ${ratioPart}, time ${timePart} min`;
if (!hasMetRequirement) {
const remaining = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.debug(`Download ${downloadInfo.name} (${indexerName}) still seeding: ${progress}`);
skipped++;
continue;
}
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement: ${progress}`);
// CRITICAL: Check if any other active (non-deleted) request is using this same download
const hashToCheck = downloadHistory.torrentHash;