diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index 61eea2e..c55ca17 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -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. 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 }`. -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. ## Architecture: Bull + Cron @@ -157,6 +157,7 @@ interface ScheduledJob { - ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled - ✅ Files deleted immediately → kept until seeding requirements met - ✅ 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 - ✅ 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 diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 6804cc2..ef89fa9 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -374,7 +374,7 @@ src/app/admin/settings/ ## Validation **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' **Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}` diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 6fc568f..46b4ab4 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -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]) diff --git a/src/app/api/admin/settings/prowlarr/indexers/route.ts b/src/app/api/admin/settings/prowlarr/indexers/route.ts index 1a54d47..ec513b7 100644 --- a/src/app/api/admin/settings/prowlarr/indexers/route.ts +++ b/src/app/api/admin/settings/prowlarr/indexers/route.ts @@ -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; } diff --git a/src/app/setup/steps/ProwlarrStep.tsx b/src/app/setup/steps/ProwlarrStep.tsx index b0ffd21..ab4ea1d 100644 --- a/src/app/setup/steps/ProwlarrStep.tsx +++ b/src/app/setup/steps/ProwlarrStep.tsx @@ -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 diff --git a/src/components/admin/indexers/IndexerConfigModal.tsx b/src/components/admin/indexers/IndexerConfigModal.tsx index b990151..98d637f 100644 --- a/src/components/admin/indexers/IndexerConfigModal.tsx +++ b/src/components/admin/indexers/IndexerConfigModal.tsx @@ -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('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({ )} - {/* Seeding Time (Torrents only) */} + {/* Seeding Time + Ratio Limit (Torrents only) */} {isTorrent && ( -
- - handleSeedingTimeChange(e.target.value)} - placeholder="0" - className={errors.seedingTimeMinutes ? 'border-red-500' : ''} - /> -

- 0 = unlimited seeding (files remain seeded indefinitely) -

- {errors.seedingTimeMinutes && ( -

- {errors.seedingTimeMinutes} -

- )} -
+ )} {/* Remove After Processing (Usenet only) */} diff --git a/src/components/admin/indexers/IndexerConfigModalTorrentFields.tsx b/src/components/admin/indexers/IndexerConfigModalTorrentFields.tsx new file mode 100644 index 0000000..1fc0f76 --- /dev/null +++ b/src/components/admin/indexers/IndexerConfigModalTorrentFields.tsx @@ -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 ( + <> +
+ + onSeedingTimeChange(e.target.value)} + placeholder="0" + className={errors.seedingTimeMinutes ? 'border-red-500' : ''} + /> +

+ 0 = unlimited seeding (files remain seeded indefinitely) +

+ {errors.seedingTimeMinutes && ( +

+ {errors.seedingTimeMinutes} +

+ )} +
+ +
+ + onRatioLimitChange(e.target.value)} + placeholder="0" + className={errors.ratioLimit ? 'border-red-500' : ''} + /> +

+ Minimum upload/download ratio before files are cleaned up. 0 = no ratio requirement. +

+ {errors.ratioLimit && ( +

+ {errors.ratioLimit} +

+ )} +
+ + ); +} diff --git a/src/components/admin/indexers/IndexerManagement.tsx b/src/components/admin/indexers/IndexerManagement.tsx index 7bc0962..cab42f2 100644 --- a/src/components/admin/indexers/IndexerManagement.tsx +++ b/src/components/admin/indexers/IndexerManagement.tsx @@ -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 diff --git a/src/lib/processors/cleanup-seeded-torrents.processor.ts b/src/lib/processors/cleanup-seeded-torrents.processor.ts index ef1b504..b045081 100644 --- a/src/lib/processors/cleanup-seeded-torrents.processor.ts +++ b/src/lib/processors/cleanup-seeded-torrents.processor.ts @@ -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; diff --git a/tests/api/admin-settings-prowlarr-indexers.routes.test.ts b/tests/api/admin-settings-prowlarr-indexers.routes.test.ts index 3dced20..21ec35a 100644 --- a/tests/api/admin-settings-prowlarr-indexers.routes.test.ts +++ b/tests/api/admin-settings-prowlarr-indexers.routes.test.ts @@ -53,7 +53,7 @@ describe('Admin Prowlarr indexers route', () => { it('saves indexer configuration', async () => { 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: [], }); @@ -63,6 +63,11 @@ describe('Admin Prowlarr indexers route', () => { expect(payload.success).toBe(true); 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 }); }); }); diff --git a/tests/app/setup/steps/ProwlarrStep.test.tsx b/tests/app/setup/steps/ProwlarrStep.test.tsx index c85a834..ae2c97b 100644 --- a/tests/app/setup/steps/ProwlarrStep.test.tsx +++ b/tests/app/setup/steps/ProwlarrStep.test.tsx @@ -15,6 +15,7 @@ const indexersMock = [ name: 'Indexer', priority: 10, seedingTimeMinutes: 0, + ratioLimit: 1.5, rssEnabled: true, categories: [], }, diff --git a/tests/components/admin/indexers/IndexerConfigModal.test.tsx b/tests/components/admin/indexers/IndexerConfigModal.test.tsx index e635573..e9e6dc4 100644 --- a/tests/components/admin/indexers/IndexerConfigModal.test.tsx +++ b/tests/components/admin/indexers/IndexerConfigModal.test.tsx @@ -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' } }); expect(priorityInput).toHaveValue(25); @@ -32,6 +32,12 @@ describe('IndexerConfigModal', () => { fireEvent.change(seedingInput, { target: { value: '-5' } }); 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'); fireEvent.click(rssToggle); @@ -43,6 +49,7 @@ describe('IndexerConfigModal', () => { name: 'Prowlarr', priority: 25, seedingTimeMinutes: 0, + ratioLimit: 1.5, rssEnabled: false, audiobookCategories: expect.arrayContaining([3030]), ebookCategories: expect.arrayContaining([7020]), diff --git a/tests/processors/cleanup-seeded-torrents.processor.test.ts b/tests/processors/cleanup-seeded-torrents.processor.test.ts index 0a1c410..2fa253e 100644 --- a/tests/processors/cleanup-seeded-torrents.processor.test.ts +++ b/tests/processors/cleanup-seeded-torrents.processor.test.ts @@ -217,6 +217,251 @@ describe('processCleanupSeededTorrents', () => { 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 () => { configMock.get.mockResolvedValue( JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])