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 -1
View File
@@ -22,7 +22,7 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search``awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release``awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default. 4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search``awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release``awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default.
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default 5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
6. **find_missing_ebooks** - Default: daily midnight, scans `downloaded` `available` audiobook requests (limit 50) for missing ebook companions and triggers the existing ebook fetch flow (`addSearchEbookJob`). Gated by `ebook_auto_grab_enabled` AND at least one ebook source enabled (`ebook_annas_archive_enabled` or `ebook_indexer_search_enabled`; legacy `ebook_sidecar_enabled` accepted as Anna's fallback). Skips ebook children in-flight (`pending`, `awaiting_approval`, `searching`, `downloading`, `processing`, `awaiting_search`, `awaiting_release`) or `cancelled`. Retries `failed`/`warn` children up to **5 lifetime auto-retries** per audiobook, tracked in `Request.ebookAutoRetryCount` (nullable; processor-private — manual "Fetch Ebook" never reads/writes it). Per-candidate writes are wrapped in `prisma.$transaction` for race-safety with concurrent auto-grab; counter rolls back if `addSearchEbookJob` throws. Enabled by default. Returns `{ scanned, gapsFound, triggered, created, retried, skippedInFlight, skippedCancelled, skippedCapHit }`. 6. **find_missing_ebooks** - Default: daily midnight, scans `downloaded` `available` audiobook requests (limit 50) for missing ebook companions and triggers the existing ebook fetch flow (`addSearchEbookJob`). Gated by `ebook_auto_grab_enabled` AND at least one ebook source enabled (`ebook_annas_archive_enabled` or `ebook_indexer_search_enabled`; legacy `ebook_sidecar_enabled` accepted as Anna's fallback). Skips ebook children in-flight (`pending`, `awaiting_approval`, `searching`, `downloading`, `processing`, `awaiting_search`, `awaiting_release`) or `cancelled`. Retries `failed`/`warn` children up to **5 lifetime auto-retries** per audiobook, tracked in `Request.ebookAutoRetryCount` (nullable; processor-private — manual "Fetch Ebook" never reads/writes it). Per-candidate writes are wrapped in `prisma.$transaction` for race-safety with concurrent auto-grab; counter rolls back if `addSearchEbookJob` throws. Enabled by default. Returns `{ scanned, gapsFound, triggered, created, retried, skippedInFlight, skippedCancelled, skippedCapHit }`.
7. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default 7. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met. Respects per-indexer `seedingTimeMinutes` AND `ratioLimit` (BOTH required when set; `0` disables that criterion; both `0` = never cleaned up). Undefined ratio with `ratioLimit > 0` = not met (safe-deny). Enabled by default.
8. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default. 8. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default.
## Architecture: Bull + Cron ## Architecture: Bull + Cron
@@ -157,6 +157,7 @@ interface ScheduledJob {
- ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled - ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled
- ✅ Files deleted immediately → kept until seeding requirements met - ✅ Files deleted immediately → kept until seeding requirements met
- ✅ No seeding time config → added `seeding_time_minutes` - ✅ No seeding time config → added `seeding_time_minutes`
- ✅ No ratio-based seeding policy → added per-indexer `ratioLimit` (AND-semantics with `seedingTimeMinutes`; `0` disables; undefined client ratio = safe-deny)
- ✅ Scheduled jobs not running on schedule → implemented Bull repeatable jobs with cron scheduling - ✅ Scheduled jobs not running on schedule → implemented Bull repeatable jobs with cron scheduling
- ✅ MaxListenersExceededWarning → increased maxListeners to 20 on both Redis client and Bull queue - ✅ MaxListenersExceededWarning → increased maxListeners to 20 on both Redis client and Bull queue
- ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder - ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder
+1 -1
View File
@@ -374,7 +374,7 @@ src/app/admin/settings/
## Validation ## Validation
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected **Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean **Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, ratioLimit ≥0 (torrents only; decimal, `0` = no requirement), rssEnabled boolean
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd' **Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}` **Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
+2
View File
@@ -169,6 +169,7 @@ export interface IndexerConfig {
enabled: boolean; enabled: boolean;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030]) audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -185,6 +186,7 @@ export interface SavedIndexerConfig {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030]) audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean; rssEnabled?: boolean;
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030]) audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
@@ -79,6 +80,7 @@ export async function GET(request: NextRequest) {
// Add protocol-specific fields // Add protocol-specific fields
if (isTorrent) { if (isTorrent) {
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0; config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
config.ratioLimit = saved?.ratioLimit ?? 0;
} else { } else {
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true; config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
} }
@@ -134,6 +136,7 @@ export async function PUT(request: NextRequest) {
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent'; const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
if (isTorrent) { if (isTorrent) {
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0; config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
config.ratioLimit = indexer.ratioLimit ?? 0;
} else { } else {
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true; config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
} }
+1
View File
@@ -25,6 +25,7 @@ interface SelectedIndexer {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches audiobookCategories: number[]; // Categories for audiobook searches
@@ -1,9 +1,6 @@
/** /**
* Component: Indexer Configuration Modal * Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
*
* Supports separate category configurations for AudioBook and EBook searches
* via tabbed interface in the Categories section.
*/ */
'use client'; 'use client';
@@ -13,6 +10,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView'; import { CategoryTreeView } from './CategoryTreeView';
import { TorrentSeedingFields } from './IndexerConfigModalTorrentFields';
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories'; import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
type CategoryTab = 'audiobook' | 'ebook'; type CategoryTab = 'audiobook' | 'ebook';
@@ -30,6 +28,7 @@ interface IndexerConfigModalProps {
initialConfig?: { initialConfig?: {
priority: number; priority: number;
seedingTimeMinutes?: number; seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean; removeAfterProcessing?: boolean;
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; audiobookCategories: number[];
@@ -41,6 +40,7 @@ interface IndexerConfigModalProps {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean; removeAfterProcessing?: boolean;
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; audiobookCategories: number[];
@@ -61,6 +61,7 @@ export function IndexerConfigModal({
const defaults = { const defaults = {
priority: 10, priority: 10,
seedingTimeMinutes: 0, seedingTimeMinutes: 0,
ratioLimit: 0,
removeAfterProcessing: true, // Default to true for Usenet removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss, rssEnabled: indexer.supportsRss,
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES, audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
@@ -74,6 +75,9 @@ export function IndexerConfigModal({
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState( const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
); );
const [ratioLimit, setRatioLimit] = useState(
initialConfig?.ratioLimit ?? defaults.ratioLimit
);
const [removeAfterProcessing, setRemoveAfterProcessing] = useState( const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
); );
@@ -89,21 +93,20 @@ export function IndexerConfigModal({
initialConfig?.ebookCategories ?? defaults.ebookCategories initialConfig?.ebookCategories ?? defaults.ebookCategories
); );
// Tab state for categories
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook'); const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
// Validation errors
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
priority?: string; priority?: string;
seedingTimeMinutes?: string; seedingTimeMinutes?: string;
ratioLimit?: string;
}>({}); }>({});
// Reset form when modal opens or indexer changes
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (mode === 'add') { if (mode === 'add') {
setPriority(defaults.priority); setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes); setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRatioLimit(defaults.ratioLimit);
setRemoveAfterProcessing(defaults.removeAfterProcessing); setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled); setRssEnabled(defaults.rssEnabled);
setAudiobookCategories(defaults.audiobookCategories); setAudiobookCategories(defaults.audiobookCategories);
@@ -111,6 +114,7 @@ export function IndexerConfigModal({
} else { } else {
setPriority(initialConfig?.priority ?? defaults.priority); setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes); setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRatioLimit(initialConfig?.ratioLimit ?? defaults.ratioLimit);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing); setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled); setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories); setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
@@ -132,6 +136,10 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative'; newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
} }
if (isTorrent && ratioLimit < 0) {
newErrors.ratioLimit = 'Ratio limit cannot be negative';
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
@@ -154,6 +162,7 @@ export function IndexerConfigModal({
// Add protocol-specific fields // Add protocol-specific fields
if (isTorrent) { if (isTorrent) {
config.seedingTimeMinutes = seedingTimeMinutes; config.seedingTimeMinutes = seedingTimeMinutes;
config.ratioLimit = ratioLimit;
} else { } else {
config.removeAfterProcessing = removeAfterProcessing; 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 // Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories; const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories; const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
@@ -238,30 +258,15 @@ export function IndexerConfigModal({
)} )}
</div> </div>
{/* Seeding Time (Torrents only) */} {/* Seeding Time + Ratio Limit (Torrents only) */}
{isTorrent && ( {isTorrent && (
<div> <TorrentSeedingFields
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> seedingTimeMinutes={seedingTimeMinutes}
Seeding Time (minutes) ratioLimit={ratioLimit}
</label> errors={errors}
<Input onSeedingTimeChange={handleSeedingTimeChange}
type="number" onRatioLimitChange={handleRatioLimitChange}
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>
)} )}
{/* Remove After Processing (Usenet only) */} {/* 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; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches audiobookCategories: number[]; // Categories for audiobook searches
@@ -133,8 +133,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
// Find matching indexer configuration by name // Find matching indexer configuration by name
const seedingConfig = indexerConfigMap.get(indexerName); const seedingConfig = indexerConfigMap.get(indexerName);
// If no config found or seeding time is 0 (unlimited) // Per-indexer thresholds. 0 disables that criterion; both 0 = unlimited.
if (!seedingConfig || seedingConfig.seedingTimeMinutes === 0) { 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 // For soft-deleted requests with unlimited seeding, hard delete immediately
if (request.deletedAt) { if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } }); await prisma.request.delete({ where: { id: request.id } });
@@ -144,7 +148,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue; continue;
} }
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60; const seedingTimeSeconds = seedingMin * 60;
// Skip if this torrent was already deleted earlier in this run // Skip if this torrent was already deleted earlier in this run
if (deletedHashes.has(clientId.toLowerCase())) { if (deletedHashes.has(clientId.toLowerCase())) {
@@ -178,17 +182,30 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue; continue;
} }
// Check if seeding time requirement is met // Check seeding requirements: AND-semantics across time and ratio.
const actualSeedingTime = downloadInfo.seedingTime || 0; // Each criterion is "met" when disabled (0) or when actual meets/exceeds it.
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds; // 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) { if (!hasMetRequirement) {
const remaining = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60); logger.debug(`Download ${downloadInfo.name} (${indexerName}) still seeding: ${progress}`);
skipped++; skipped++;
continue; 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 // CRITICAL: Check if any other active (non-deleted) request is using this same download
const hashToCheck = downloadHistory.torrentHash; const hashToCheck = downloadHistory.torrentHash;
@@ -53,7 +53,7 @@ describe('Admin Prowlarr indexers route', () => {
it('saves indexer configuration', async () => { it('saves indexer configuration', async () => {
authRequest.json.mockResolvedValue({ authRequest.json.mockResolvedValue({
indexers: [{ id: 1, name: 'Indexer', protocol: 'torrent', enabled: true, priority: 10, seedingTimeMinutes: 0 }], indexers: [{ id: 1, name: 'Indexer', protocol: 'torrent', enabled: true, priority: 10, seedingTimeMinutes: 0, ratioLimit: 1.5 }],
flagConfigs: [], flagConfigs: [],
}); });
@@ -63,6 +63,11 @@ describe('Admin Prowlarr indexers route', () => {
expect(payload.success).toBe(true); expect(payload.success).toBe(true);
expect(configServiceMock.setMany).toHaveBeenCalled(); expect(configServiceMock.setMany).toHaveBeenCalled();
const setManyArg = configServiceMock.setMany.mock.calls[0][0];
const indexersEntry = setManyArg.find((e: any) => e.key === 'prowlarr_indexers');
expect(indexersEntry).toBeDefined();
const persisted = JSON.parse(indexersEntry.value);
expect(persisted[0]).toMatchObject({ id: 1, ratioLimit: 1.5, seedingTimeMinutes: 0 });
}); });
}); });
@@ -15,6 +15,7 @@ const indexersMock = [
name: 'Indexer', name: 'Indexer',
priority: 10, priority: 10,
seedingTimeMinutes: 0, seedingTimeMinutes: 0,
ratioLimit: 1.5,
rssEnabled: true, rssEnabled: true,
categories: [], categories: [],
}, },
@@ -24,7 +24,7 @@ describe('IndexerConfigModal', () => {
/> />
); );
const [priorityInput, seedingInput] = screen.getAllByRole('spinbutton'); const [priorityInput, seedingInput, ratioInput] = screen.getAllByRole('spinbutton');
fireEvent.change(priorityInput, { target: { value: '99' } }); fireEvent.change(priorityInput, { target: { value: '99' } });
expect(priorityInput).toHaveValue(25); expect(priorityInput).toHaveValue(25);
@@ -32,6 +32,12 @@ describe('IndexerConfigModal', () => {
fireEvent.change(seedingInput, { target: { value: '-5' } }); fireEvent.change(seedingInput, { target: { value: '-5' } });
expect(seedingInput).toHaveValue(0); expect(seedingInput).toHaveValue(0);
fireEvent.change(ratioInput, { target: { value: '-0.5' } });
expect(ratioInput).toHaveValue(0);
fireEvent.change(ratioInput, { target: { value: '1.5' } });
expect(ratioInput).toHaveValue(1.5);
const rssToggle = screen.getByRole('checkbox'); const rssToggle = screen.getByRole('checkbox');
fireEvent.click(rssToggle); fireEvent.click(rssToggle);
@@ -43,6 +49,7 @@ describe('IndexerConfigModal', () => {
name: 'Prowlarr', name: 'Prowlarr',
priority: 25, priority: 25,
seedingTimeMinutes: 0, seedingTimeMinutes: 0,
ratioLimit: 1.5,
rssEnabled: false, rssEnabled: false,
audiobookCategories: expect.arrayContaining([3030]), audiobookCategories: expect.arrayContaining([3030]),
ebookCategories: expect.arrayContaining([7020]), ebookCategories: expect.arrayContaining([7020]),
@@ -217,6 +217,251 @@ describe('processCleanupSeededTorrents', () => {
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true); expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true);
}); });
it('cleans up when ratio-only requirement is met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'RatioIndexer', seedingTimeMinutes: 0, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-ratio-1',
name: 'Ratio Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60,
ratio: 1.5,
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-ratio-1',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'RatioIndexer',
torrentHash: 'hash-ratio-1',
downloadClientId: 'hash-ratio-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-ratio-1' });
expect(result.cleaned).toBe(1);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ratio-1', true);
});
it('skips when both criteria set, time met but ratio not met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'BothIndexer', seedingTimeMinutes: 30, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-both-1',
name: 'Both Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 40,
ratio: 0.5,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-both-1',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'BothIndexer',
torrentHash: 'hash-both-1',
downloadClientId: 'hash-both-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-both-1' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('skips when both criteria set, ratio met but time not met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'BothIndexer', seedingTimeMinutes: 30, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-both-2',
name: 'Both Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 10,
ratio: 1.5,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-both-2',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'BothIndexer',
torrentHash: 'hash-both-2',
downloadClientId: 'hash-both-2',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-both-2' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('cleans up when both criteria set and both met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'BothIndexer', seedingTimeMinutes: 30, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-both-3',
name: 'Both Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 40,
ratio: 1.5,
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-both-3',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'BothIndexer',
torrentHash: 'hash-both-3',
downloadClientId: 'hash-both-3',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-both-3' });
expect(result.cleaned).toBe(1);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-both-3', true);
});
it('skips when ratio-only requirement set but client reports no ratio', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'RatioIndexer', seedingTimeMinutes: 0, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-noratio-1',
name: 'No-Ratio Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 100,
// ratio intentionally omitted (undefined)
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-noratio-1',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'RatioIndexer',
torrentHash: 'hash-noratio-1',
downloadClientId: 'hash-noratio-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-noratio-1' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('detects shared torrents across audiobook and ebook requests', async () => { it('detects shared torrents across audiobook and ebook requests', async () => {
configMock.get.mockResolvedValue( configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }]) JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])