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