mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const indexersMock = [
|
||||
name: 'Indexer',
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
ratioLimit: 1.5,
|
||||
rssEnabled: true,
|
||||
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' } });
|
||||
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]),
|
||||
|
||||
@@ -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 }])
|
||||
|
||||
Reference in New Issue
Block a user